HEINZ NIXDORF INSTITUT Fakultät für Elektrotechnik, Informatik und Mathematik. Seminar»Verteilte Algorithmen«Wintersemester 2006/2007

Größe: px
Ab Seite anzeigen:

Download "HEINZ NIXDORF INSTITUT Fakultät für Elektrotechnik, Informatik und Mathematik. Seminar»Verteilte Algorithmen«Wintersemester 2006/2007"

Transkript

1 HEINZ NIXDORF INSTITUT Fakultät für Elektrotechnik, Informatik und Mathematik Seminar»Verteilte Algorithmen«Wintersemester 2006/2007 Veranstalter Prof. Dr. Friedhelm Meyer auf der Heide Dr. Matthias Fischer Fachgruppe: Algorithmen und Komplexität

2

3 Inhaltsverzeichnis Jonas Schulte Grundlegende Datenstrukturen für Externe Speicherung... 1 Oliver Buschjost A Survey of Techniques for Designing I/O-Efficient Algorithms Christoph Horstmann I/O effiziente Algorithmen auf dünnen Graphen Raphael Golombek Algorithmische Geometrie im externen Speicher André Braun Volltextindizes im externen Speicher Stefan Frank Algorithmen für Hardware Caches und TLBs Tobias Berghoff Cacheoptimierungen für Numerische Algorithmen Felix Pottmeyer Memory Limitations in Artificial Intelligence Elmar Köhler Algorithmen für Speichernetzwerke David Brucholder An Overview of File System Architectures - Multiple Disk Prefetching Strategies for External Merging Michael Blank Exploitation of the Memory Hierarchy in Relational DBMSs. 179 Martin Klose Parallel Bridging Models und ihre Emulation Stefan Finke Fallstudie: Speicherbewußtes, paralleles Sortieren

4

5 1 Grundlegende Datenstrukturen für Externe Speicherung Jonas Schulte 1 Einleitung Eine Vielzahl unterschiedlicher Applikationen, wie zum Beispiel Data-Warehouse Anwendungen oder umfangreiche Simulationen, verwenden oder produzieren enorme Datenmengen. Die fortschrittliche Weltraumforschung der NASA ist als weiteres Beispiel zu nennen, die es ermöglicht, detaillierte Satellitenbilder der Erde aufzunehmen. Je mehr Details ein Satellitenbild beinhaltet, desto mehr Speicherbedarf benötigt es. So liegt der insgesamte Speicherbedarf der NASA für die Satellitenbilder zur Zeit bei einigen Petabytes. Eine Speicherung dieser Daten im Speicher eines Prozessors ist nicht realisierbar, die Nutzung von Arbeitsspeicher würde bei aktuellen Preisen pro Petabyte etwa 1 Milliarde Euro kosten. 1 Eine deutlich preiswertere Lösung mit etwa 2,85 Millionen Euro pro Petabyte ist die Nutzung von Festplattenspeicher. 2 Da der insgesamte Speicherbedarf für die Satellitenbilder enorm hoch ist und zudem verschiedene Applikationen die Daten verwenden, ist es gegenbenenfalls erforderlich, zusätzliche Speichermedien zu verwenden, die sich nicht im ausführenden Rechner befinden (in einem solchen Fall erfolgt der Datenzugriff über ein Intranet oder das Internet). Dieser Aspekt verdeutlicht, dass Datenzugriffe auf externe Speichermedien in einer höhere Latenzeit resultieren, welche zum Einen von der Bandbreite zwischen Speichermedium und Prozesssor und zum Anderen auch von der Datenbereitstellungszeit des Mediums abhängt. Wird eine Festplatte im gleichen Rechner genutzt, so kann die Latenzzeit verursacht durch die Übertragung der Daten vernachlässigt werden, da mehrere aufeinanderfolgende Bits gleichzeitig übertragen werden. Die korrekte Positionierung des Lese-/Schreibkopfes für den Datenzugriff ist deutlich zeitintensiver. Folglich ist das Ziel der Datenorganisation und der Algorithmen auf externen Speichermedien die Minimierung der Zugriffszeit auf die benötigten Daten, und damit der Anzahl der erforderlichen Positionierungen des Lese-/Schreibkopfes. Die Abbildung 1 verdeutlicht, dass eine geschickte Verteilung der benötigten Daten weniger Positionierungsvorgänge erfordert als eine willkürliche Verteilung. Folglich ist die Anzahl der erforderlichen Repositionierungen ein erhebliches Kriterium für die Effizienz von Datenstrukturen und Algorithmen. 1 dabei wird angenommen, dass 1GB Arbeitsspeicher etwa 100 Euro kosten 2 unter der Annahme, dass 350 GB etwa 100 Euro kosten

6 2 Abbildung 1: Nicht aufwendige und aufwendige Repositionierung für den Datenzugriff Datenstrukturen, die im internen Speicher einen effizienten Datenzugriff ermöglichen, sind in den meisten Fällen im externen Speicher nicht leistungsstark. Diese Seminararbeit zeigt für verschiedene Datenstrukturen (Stack, Queue, B-Bäume und Hashtabellen) wie sie effizient in externen Speicher realisiert werden können. Das Schlüsselkonzept ist dabei oftmals eine Datenspeicherung ähnlicher Daten in physikalischer Nähe. Diese Seminararbeit stellt zunächst in Kapitel 2 die Grundlagen der Speicherkomponenten sowie das externe Speichermodell vor, welches bei den entwickelten Datenstrukturen zu Grunde gelegt wurde. Die optimalen Grenzen für Datenzugriffe beim Lesen oder Schreiben werden ebenfalls präsentiert. Anschließend werden in Kapitel 3 die Datenstrukturen externer Stack und externe Queue im Vergleich zu ihren Realisierungen im internen Speicher vorgestellt. Das Kapitel verdeutlicht, dass bereits für solche elementaren Datenstrukturen die im Hauptspeicher performante Realisierung sich im externen Speicher als außerordentlich ineffizient erweist. Die Implementierung von Wörterbüchern kann mit Hilfe von Bäumen oder Hashtabellen erfolgen. Folglich behandeln Kapitel 4 sowie 5 diese beiden Datenstrukturen für die Realisierung im externen Speicher. Abschließend fasst Kapitel 6 die Ergebnisse dieser Seminararbeit zusammen.

7 3 2 Grundlagen Dieses Kapitel stellt die Grundlagen für die Seminararbeit vor. Dabei wird zunächst die Speicherhierarchie sowie der aktuelle Stand der Technik von Speicherkomponeten beschrieben (siehe Abschnitt 2.1). Die entwickelten Datenstrukturen basieren auf einem in der Theorie häufig verwendeten externen Speichermodell, welches in Abschnitt 2.2 präsentiert wird. Zum Leistungsvergleich der entwickelten Datenstrukturen ist es notwendig, die optimalen Grenzen zu kennen. Abschnitt 2.3 gibt dazu einen Überblick über die optimalen Werte, das heißt die unteren Grenzen. Soweit nicht anders verwiesen, basieren die Informationen auf [MSS03, Kapitel 1-2]. 2.1 Speicherkomponenten In der Einleitung wurden vor allem der Hauptspeicher und der Festplattenspeicher fokussiert. Jedoch werden die Speicherkomponenten gemäß der Speicherhierarchie (siehe Abbildung 2) unterschieden in Register, Cache, Hauptspeicher sowie Festplattenspeicher. Je schneller der Datenzugriff desto teurer ist der Speicherplatz. Die Notwendigkeit einer Speicherhierachie folgt somit daraus, dass Daten preiswert gespeichert werden sollen aber auch ein schneller Zugriff erforderlich ist. Der Prozessor besitzt verschiedene Caches für Befehle und Daten. Die meisten aktuellen Prozessoren verfügen über einen L1 und L2 Cache, einige sind zudem mit einem L3 Cache ausgestattet. Die Größe der Caches sind unterschiedlich für verschiedene Prozessormodelle, jedoch wächst die Größe von L1 über L2 zu L3 Cache wie sich im Gegenzug die Latenzzeit erhöht. In den meisten Fällen sind die L1 Caches nur wenige Kilobyte groß, ermöglichen jedoch ein oder zwei Zugriffe pro CPU-Takt. Ein Datenzugriff auf den L1 Cache verursacht eine Latenzzeit von einigen Takten, da mehrere Schritte der befehlsdurchführenden Pipelines durchschritten werden müssen. Der L2 Cache ist bei heutigen Prozessoren 2 oder 4 MB groß und hat eine Zugangslatenz von etwa 10 Takten. L3 Caches befinden sich nicht mehr auf dem gleichen Chip wie der Prozessor und bestehen aus schnellen statischen RAM-Zellen. Der L3 Cache kann im Prinzip sehr groß sein. Oftmals ist ein großer L3 Cache aus Kostengründen jedoch nicht effizient. Der Arbeitsspeicher, auch Hauptspeicher genannt, besteht aus günstigen dynamischen RAM Zellen.

8 4 Abbildung 2: Speicherhierarchie Es ist nicht notwendig, dass Programmierer den detailierten Speicheraufbau kennen müssen. Die Hardware teilt den Hauptspeicher in Blöcke und weist eine Teilmenge dieser automatisch dem L3 Cache zu. Eine Teilmenge der Daten im L3 Cache werden dem L2 Cache übermittelt, für den L1 Cache gilt dieses analog. Als Hintergrundspeicherung werden Festplatten angesehen, da sie sehr kostengünstige und permanente Speicherung ermöglichen. Die Schnelligkeit von Festplattenzugriffen hat sich in den letzten Jahren zunehmend erhöht, so dass sich die Latenzzeit bei Speicherung auf Festplatten fortlaufend reduziert. Jedoch ist ein Festplattenzugriff immer noch mal langsamer als ein Registerzugriff [MSS03]. Die beschriebenen Speicherkomponenten sind in der Speicherhierarchie (siehe Abbildung 2) eingeordnet. Die Abbildung zeigt, dass mit höherer Latenzzeit für den Datenzugriff der Preis pro Byte sinkt. Das Ausmaß des Preisunterschiedes wurde in der Einleitung anhand des NASA Beispiels verdeutlicht: 1 Millarden Euro pro Petabyte für Speicherung im Arbeitsspeicher stehen im Gegensatz zu 2,85 Millionen Euro bei Festplattennutzung. Es sei angemerkt, dass trotz der höheren Latenzzeit bei Nutzung von Festplattenkapazität dieser Speicherplatz oftmals als SWAP-Bereich verwendet wird, um den virtuellen Speicher eines Rechners zu vergrößern. Dies zeigt, dass die Bandbreite zwischen Arbeitsspeicher und Hauptspeicher eine genügende Effizienz bietet und die Datenorganisation auf dem externen Medium ausschlaggebend für die Performance ist. Wird Festplattenkapazität als SWAP-Bereich verwendet, erfolgt das Mapping von virtuellen Speicheradressen mittels Translation Lookaside Buffer (TLB) [MSS03, Kapi-

9 5 tel 8] in physikalische Speicheradressen. Abschnitt geht auf die Realisierung des virtuellen Arbeitsspeichers mit Hilfe von Wörterbüchern ein. 2.2 Externes Speichermodell Der vorige Abschnitt verdeutlicht, dass die Speicherhierarchie sehr komplex ist und viele Abstufungen besitzt. Zudem verfügen Hardware Caches über verschiedene komplexe Ersetzungsstrategien [FHL + 01] und Festplatten über positionsabhängige Zugriffsverzögerungen. Für die Entwicklung von Datenstrukturen können solche Eigenschaften nur schwer in Betracht genommen werden, da schwierige Modelle in komplexen Realisierungen und Analysen von Datenstrukturen resultieren. Ein Vergleich verschiedener Datenstrukturen, welche auf unterschiedlichen Modellen basieren, ist bei komplexen Modellen nicht möglich, da eine Vielzahl von Parametern die Effizienz und Implementierung beeinflussen können. Aus diesen Gründen wurden einfache Modelle entwickelt, so dass bei der Entwicklung von Datenstrukturen nicht viele verschiedene Parameter berücksichtigt werden müssen. Es gilt zu beachten, dass das zu Grunde gelegte Modell nicht zu einfach sein darf, da sich die im Modell effizienten Datenstrukturen auch in der Praxis bewähren sollen. Die Entwicklungen dieser Seminararbeit basieren auf dem am weitesten verbreiteten Modell (siehe Abbildung 3). Es wird das externe Speichermodell genannt und ist eine kleine Erweiterung der Random Access Machine (RAM)/ von Neumann Model [vn45]. Trotz seiner starken Abstraktion der Speicherhierarchie auf 2 Ebenen, wird dieses Modell für die theoretische Analyse und Entwicklung von Algorithmen und Datenstrukturen häufig verwendet (es ist in mehr als in 100 veröffentlichten wissenschaftlichen Arbeiten zu finden). Vitter hat gezeigt, dass viele in diesem Modell effiziente Entwicklungen ebenfalls in der Praxis performant waren [Vit01]. Folglich ist die starke Abstraktion keine schwerwiegende Einschränkung der realen Umgebung. Definition 2.1. Die in der Seminararbeit verwendete Notation entspricht der von Aggarwal, Vitter und Shriver Eingeführten [AV88, VS94]: N Anzahl der Datenelemente M Anzahl der Datenelemente, die im internen Speicher gehalten werden können

10 6 B Anzahl der aufeinanderfolgende Datenelemente, die mit einer Ein-/ oder Ausgabeoperation (I/O) zwischen internem und externem Speicher ausgetauscht werden können. Dies entspricht zudem der Kapazität eines externen Speicherblocks, gemessen in Datenelementen Abbildung 3 zeigt eine grafische Darstellung des externen Speichermodells. Für die Performanceanalyse von externen Speicheralgorithmen wird die Anzahl der I/O- Operationen gemessen, da die Bandbreite nicht der Flaschenhals der Auslagerung ist. Des Weiteren wird angenommen, dass der Datenzugriff im internen Speicher sowie arithmetische Operationen konstante Zeit benötigen. Abbildung 3: Externes Speichermodell [MSS03, S.6] Das Modell besagt, dass mit einer I/O-Operation B Datenelemente zwischen internem und externem Speicher ausgetauscht werden können. Jedoch kann diese Aussage trügerisch sein, da der externe Speicher nicht zwanghaft mit einer Festplatte gleichgesetzt werden soll. Das externe Speichermodell hat keine Restriktionen, welche Level der Speicherhierarchie mit dem Modell abgebildet werden. Folglich könnte ebenfalls der L2 Cache mit dem schnellen internen Speicher und der Arbeitsspeicher mit dem großen Speicher abgebildet werden. Ungenauigkeiten des Modells sind meistens mit Hilfe von konstanten Faktoren anzupassen. Ebenso sei angemerkt, dass Festplatten auch über lokale Caches verfügen. Jedoch sind diese so klein, dass sie für die meisten Algorithmen keine Effizienzsteigerung bedeuten. Die Zugriffszeit auf eine Festplatte besteht zum einen aus der Latenzzeit den Lese- /Schreibkopf an die korrekte Position zu setzen und zum anderen aus eine Übermittlungszeit, welche proportional zur Datenmenge ist. Die Latenzzeit für die Positionierung des Lese-/Schreibkopfes hängt von der aktuellen Position sowie von der Position

11 7 des Speicherblocks auf der Festplatte ab. Wie zuvor erwähnt, ist die Übermittlungszeit durch einen ausreichend hohe Bandbreite für mehrere Elemente performant und zudem gleich für gleiche Datenmengen. Damit ist die Übermittlungszeit kein Effizienzkriterium bei der Analyse von externen Datenstrukturen. Die in dieser Seminararbeit vorgestellten Datenstrukturen basieren nicht nur auf dem externen Speichermodell. Es wird zudem folgende Bedingung angenommen (verdeutlicht in den Abbildungen 4 und 5): Annahme 2.1. Beim Auslagern eines Speicherblocks beliebiger Größe können die Daten im externen Speicher so abgelegt werden, dass der Zugriff auf diesen Speicherblock nur eine I/O- Operation benötigt. Abbildung 4: Diese Situation darf bei der Auslagerung eines Speicherblocks nicht existieren. Abbildung 5: Bei der Auslagerung eines Speicherblocks ist diese Situation gefordert. Annahme 2.2. Bei der Analyse der Datenstrukturen in dieser Seminararbeit wird angenommen, dass pro I/O-Operation B Datenelemente aus bzw. in einen Speicherblock gelesen bzw. geschrieben werden können. Der Flaschenhals der Effizienz ist somit die Anzahl von Positionierungen, um alle erforderlichen Daten für den Datenaustausch bereitzustellen, folglich soll die Anzahl an I/O-Operationen minimiert werden. Zur Vereinfachung der Notation wird zudem angenommen, dass der Logarithmus einer Zahl mindestens Eins ergibt, d.h. log a b sollte gelesen werden als max(log a b, 1).

12 8 2.3 Untere Leistungsgrenzen Viele Algorithmen für externe Speicher können aus den drei Grundoperationen Scannen, Sortieren und Suchen gebildet werden. [MSS03, Kapitel 1] stellt die in diesem Abschnitt enthaltenen untere Grenzen vor. Dabei gilt es Folgendes zu beachten: Für die Speicherung von N Datenelementen und einer Speicherblockgröße von maximal B Datenelementen ist N/B die minimale Anzahl von benötigten Speicherblöcken für die Haltung von N Datenelementen. Für eine detaillierte Analyse von unteren Grenzen wird auf Lars Arge and Peter Bro Miltersen [AM99] verwiesen. Scannen bedeutet, dass die enthaltenen Daten der Datenstruktur einmal in der Reihenfolge durchlaufen werden, in welcher sie abgespeichert sind. Folglich basiert die untere Grenze für das Scannen auf der minimalen Anzahl von Speicherblöcken um N Datenelemente abzulegen: scan(n) = Θ ( ) N I/Os (1) B Permutation und Sortieren ist oftmals erforderlich, um die Daten für das Scannen in eine nützliche Reihenfolge zu bringen. Eine Permutation erfolgt, wenn die neue Position des Elementes bereits bekannt ist. Ist die neue Position nur implizit anhand einer Ordnung, wie zum Beispiel <, bestimmt, so entspricht dies der Sortierung von Daten bezüglich <. [MSS03, Kapitel 3] zeigt den optimalen Aufwand für das Sortieren: ( N sort(n) = Θ B log M B Das Permutieren besitzt eine ähnliche untere Schranke. ) N I/Os (2) B Suchen in einer Datenstruktur mit N Elementen im externen Speicher, deren Zugriff über Zeiger erfolgt, wird im Folgenden analysiert. Die Zeigeradressierung bedeutet, dass auf einen Block i nur zugegriffen werden darf, wenn seine Adresse wirklich irgendwo gespeichert ist. Es ist notwendig die Anzahl der unterschiedlichen Blöcke C t zu zählen,

13 9 auf die nach t I/O-Operationen zugegriffen werden darf. Um auf alle Elemente zugreifen zu können, wird diese Anzahl gemäß der minimalen Blockanzahl den Wert N/B übersteigen. Bei der Initiierung sei der schnelle interne Speicher vollständig mit Zeigern ausgefüllt, folglich ist C 0 = M. Das Lesen jedes zusätzlichen Blockes erhöht die Anzahl um B Möglichkeiten, so dass C t = MB t gilt. Unter Betrachtung der minimalen Blockanzahl N kann C t N/B gelöst werden, welches zu der Ungleichung N log B führt. Da ein MB zusätzlicher Zugriff notwending ist, um ein Element zu erhalten, ergibt sich eine untere N Grenze von log B I/Os um N Elemente zu betrachten. Die minimale Zugriffszeit für M eine Suche in N Elementen ist somit ( ) N search(n) = Ω log B I/Os (3) M Die Effizienz der Datenstrukturen dieser Seminararbeit wird in den meisten Fällen mit Hilfe der amortisierten Laufzeitanalyse bestimmt: Definition 2.2. Die amortisierte Laufzeitanalyse wird in der theoretischen Informatik verwendet, um die durschnittliche Zeit von einer Folge von Operationen zu bestimmen [RL90, Kapitel 18]. Die amortisierte Laufzeit kann aufweisen, dass die durschnittlichen Kosten einer Operation gering sind, auch wenn eine einzelne Operation teuer sein kann. Sie unterscheidet sich von der durchschnittlichen Laufzeitanalyse (average case), da in der amortisierten Laufzeitanalyse der durschnittliche Aufwand einer Operation im worst case betrachtet wird.

14 10 3 Elementare Datenstrukturen Effiziente Realisierungen der elementaren Datenstrukturen Stack (Abschnitt 3.1), Queue (siehe Abschnitt 3.2) und verkettete Liste (Abschnitt 3.3) für die Verwendung im externen Speicher werden in diesem Kapitel vorgestellt. Dabei werden die Unterschiede zu den Implementierungen im internen Speicher und ihre ineffiziente Nutzung im externen Speicher herausgestellt. Zudem werden in Abschnitt 3.4 das Konzept sowie ein Anwendungsbeispiel von Wörterbüchern vorgestellt. Die Implementierung eines Wörterbuches kann mit Hilfe von B-Bäumen oder Hashtabellen realisiert werden, welche in den Kapiteln 4 und 5 behandelt werden. 3.1 Stack Der Stack verwaltet eine dynamische Menge von Elementen und unterstützt die Operationen des Hinzufügens/Schreibens und des Auslesens, welches ein Löschen beinhaltet. Das Lesen (Löschen) erfolgt nach dem LIFO-Prinzip (Last In First Out). Bei einer festen maximalen Datenanzahl N kann als effiziente Implementierung für den internen Speicher der Stack als Array implementiert werden. Wird diese Realisierung ebenfalls im externen Speicher verwendet, so kann im worst case eine Stack-Operation eine I/O-Operation kosten: Beim abwechselnden Schreiben und Lesen kann nämlich nicht ausgenutzt werden, dass eine I/O-Operation B Elemente schreiben oder lesen kann. Das Ausnutzen der Bandbreite von B Elementen durch Integration der oft verwendeten Technik des Buffers führt zu einer deutlichen Leistungssteigerung. Der externe Stack wird mit Hilfe eines Buffers realisiert, der ein Array im internen Speicher zur Aufnahme von 2B Elementen entspricht. Dieser Buffer beinhaltet die k zuletzt im Stack eingefügten Elemente, wobei k 2B gelten muss. Das Auslesen/Löschen eines Elementes kann somit direkt das entsprechende Element aus dem Buffer im internen Speicher lesen, außer wenn der Buffer leer ist. Ist der Buffer nicht leer, wird folglich keine I/O-Operation für das Löschen benötigt. Ist der Buffer leer, werden B Elemente aus dem externen Speicher ausgelesen und in den Buffer gelesen. Folglich kostet das Auslesen von mindestens B Elementen eine I/O-Operation. Mit einer I/O-Operationen können gegebenenfalls mehr als B Elemente gelesen werden, wenn zwischen den Löschoperationen weitere Elemente eingefügt werden. Das Einfügen von Elementen erfolgt

15 11 Abbildung 6: Konzept des externen Stacks direkt im internen Speicher. Ist also der Buffer vor dem Schreiben nicht mit 2B Elementen vollständig belegt, so wird keine I/O-Operation benötigt, da lediglich auf den internen Speicher zugegriffen wird. Ist der Buffer vollständig belegt und ein weiteres Element soll eingefügt werden, so müssen Elemente in den externen Speicher ausgelagert werden. Die Auslagerung nutzt die Bandbreite zum externen Speicher aus, und es werden B Elemente gleichzeitig aus dem Buffer in den externen Speicher transferiert. Somit können mit einer I/O-Operation mindestens B Elemente in den Stack gelesen werden. Die Mindestanzahl resultiert aus der gleichen Argumentation wie beim Auslesen. Abbildung 6 verdeutlicht die Nutzung des Buffers und die Auslagerung der Daten im externen Speicher. Laufzeitanalyse 3.1. Im worst case kosten B Einfügeoperationen (oder B Löschoperationen) eine I/O- Operation. Damit hat eine Einfüge- oder Ausleseoperation die amortisierte Laufzeit von 1 I/O-Operationen. B Da nicht mehr als B Elemente mit einer I/O-Operation gelesen oder geschrieben werden können, entspricht diese Laufzeit der optimalen Effizienz. Es sei angemerkt, dass viele Datenstrukturen für externe Speicher bei der Übermittlung von B Elementen darauf abzielen, O( 1 ) I/O-Operationen zu erreichen. Die Optimalität der gewählten Buffergröße B 2B wird in Abschnitt untersucht.

16 12 Eine alternative Beschreibung der Vorgehensweise des externen Stacks ist, dass ein Stack Speicherblöcke wie Datenelemente behandelt. Schließlich werden beim Aus- oder Einlesen immer B Elemente zwischen internen und externen Speicher verschoben. Der externe Speicher besitzt folglich eine Makroskropische Sicht auf die Daten, wohingegen der interne Speicher eine Mikroskopische Sicht hat Wahl der Buffergröße Der externe Stack verwendet einen Buffer der Größe 2B und erreicht eine amortisierte Laufzeit von 1 I/O-Operationen. Diese Effizienz ist optimal und kann nicht verbessert B werden, jedoch soll dieser Abschnitt verdeutlichen, warum eine größere Buffergröße von xb (mit x > 2) oder eine kleinere Buffergröße von B keine bessere Performanz ermöglicht. Eine größere Buffergröße ruft keinen Leistungszuwachs hervor, da maximal B Elemente zwischen internen und externem Speicher ausgestauscht werden können. Folglich ist eine bessere amortisierte Laufzeit nicht erreichbar und ein größerer Buffer überflüssig. Abbildung 7: Worst Case bei Buffergröße B Habe der Buffer eine Größe von B, dann tritt der worst case bei folgender Reihenfolge der auszuführenden Operationen auf: 1x Schreiben 2x Lesen 2 x Schreiben... (siehe Abbildung 7). Es ist zu erkennen, dass eine I/O-Operation für 2 Stack-Operationen benötigt wird. Dies ist bei B >> 2 deutlich schlechter als bei einer Buffergröße von 2B.

17 Queue Die Queue ist dem Stack sehr ähnlich, lediglich wird für das Lesen und Schreiben das FIFO-Prinzip (First-In-First-Out) verfolgt (siehe Abbildung 8). So werden beim Lesen die Elemente ausgegeben (und gelöscht), welche am längsten in der Queue gespeichert wurden. Die Queue wird im internen Speicher bei einer maximalen Eingabemenge von N Elementen wie der Stack als Array implementiert. Das Konzept des Buffers ermöglicht auch für die externe Queue eine gute Effizienz. Jedoch wird nicht ein Buffer der Größe 2B gehalten, da die Schreiboperationen einen anderen Teil der Daten effektieren als die Leseoperationen. Es werden zwei Buffer, jeweils der Größe B, im internen Speicher eingerichtet. Ein Buffer ist zuständig für den Anfang der Queue und hält die k Elemente, welche am frühesten eingefügt wurden. Der andere Buffer hält das Ende der Queue, kann Schreibbuffer genannt werden, und beinhaltet die k zuletzt eingefügten Elemente. Dabei gilt in beiden Fällen k B. Abbildung 8: FIFO-Prinzip der Queue Die Auslagerung von Elementen aus dem Schreibbuffer des internen Speichers in den externen Speicher erfolgt, wenn der Schreibbuffer überläuft. Ist der Lesebuffer leer, werden die nächsten B Elemente aus dem externen Speicher in den internen Speicher geladen. Beinhaltet der externe Speicher beim Nachladen des Lesebuffers keine Elemente der Queue, so werden die Elemente des Schreibbuffers in den Lesebuffer geladen. Laufzeitanalyse 3.2. Der worst case entspricht allen Fällen, in denen Daten im externen Speicher enthalten sind. Dann kosten B Einfügeoperationen (oder B Löschoperationen) eine I/O- Operation. Damit ergibt sich die amortisierte Laufzeit für das Schreiben oder Lesen von 1 B I/O-Operationen. Sind keine Daten im externen Speicher und nur in den Lese- und Schreibbuffer enthalten, resultieren Queue-Operationen in keiner I/O-Operation. Der Datenzugriff im internen Speicher wird als konstant angesehen.

18 14 Es ist zu beachten, dass für die vorgestellten Realisierungen des externen Stacks und der externen Queue eine maximale Eingabegröße N definiert sein muss, da die Daten im externen Speicher in einem Array von Speicherblöcken der Länge N/B 2 gespeichert werden. Um eine beliebige Anzahl von Elementen zu sichern, bedarf es der Verwendung von Pointern. Der nächste Abschnitt stellt verkettete Listen vor, welche Pointer nutzen, um eine Verbindung zu den nächsten Speicherblöcken herstellen zu können. Wird der externe Stack oder die externe Queue nicht mit einem Array, sondern mit einer verketteten Liste realisiert, ist die Festlegung der maximalen Eingabedaten nicht erforderlich. 3.3 Verkettete Liste Verkette Listen ermöglichen eine effiziente Speicherung von Elementen, die einer Ordnung zu Grunde liegen. Sie unterstützen die sequentielle Suche sowie das Löschen oder Einfügen von Elementen an einer beliebigen Postition der Liste. Wie auch beim Stack und der Queue kann die interne Realisierung bei Anwendung im externen Speicher zu einer I/O-Operation pro Listenoperation führen. Folglich ist es auch für diese elementare Datenstruktur erforderlich, eine effiziente Implementierung für den externen Speicher zu entwickeln. Die Schlüsselidee ist wie bei vielen anderen externen Datenstrukturen die Bewahrung von Lokalität. Lokalität bedeutet, dass Elemente, die in der Liste eine geringe Distanz haben, auch im externen Speicher nah beieinander sein sollen. Die erste Idee ist es, B aufeinander folgende Elemente in einem Speicherblock im externen Speicher zu sichern. In diesem Fall würde ein Durchlaufen einer Liste der Länge N N/B I/Os kosten. Es ist jedoch schwierig und aufwendig bei einem Einfügen oder Löschen die Invariante sicherzustellen, dass genau B Elemente in einem Speicherblock enthalten sind. Die Wiederherstellung der Invariante kostet schließlich O ( N/B ) I/Os, da im worst case alle Speicherblöcke modifiziert werden müssen. Eine effizientere Realisierung ist unter der Nutzung von mehr Speicherplatz im externen Speicher sowie einer Abschwächung der Invariante möglich.

19 15 Abbildung 9: Einfügen ohne Anpassung der Speicherblöcke In dieser Seminararbeit wird die Invariante als Beispiel so abgeschwächt, dass in jeweils zwei aufeinander folgenden Blöcken insgesamt mehr als 2 B Elemente enthalten sein müssen. Durch diese Invariante erhöht sich die Gesamtanzahl aller genutzten 3 Speicherblöcke höchstens um den Faktor 3, welches mit der Erhöhung von I/Os für einen sequentiellen Scan einhergeht. Der Faktor 3 ergibt sich dadurch, dass zwei aufeinander folgende Blöcke nur 2 B statt bei Ausnutzung der vollen Kapazität 2B Elemente beinhalten. Das 3 Einfügen in einen Block kostet eine I/O, wenn der Block noch freie Kapazität besitzt (siehe Abbildung 9). Falls der gewählte Block S i die maximale Kapazität ausgeschöpft hat und einer seiner Nachbarn S j weniger als B Elemente besitzt, wird ein entsprechendes Element von S i nach S j verlegt (siehe Abbildung 10). Dabei gilt es zu beachten, dass die Ordnung der Elemente durch die Verlegung nicht verletzt werden darf. Folglich ist das Element, welches dem anderen Speicherblock zugewiesen wird, entweder das kleinste (S j ist Vorgänger von S i ) oder das größte Element (S j ist Nachfolger von S i ) in S i. Die Invariante wird in keiner dieser beiden Fällen verletzt, da die Speicherblöcke lediglich ein weiteres Element erhalten.

20 16 Abbildung 10: Einfügen mit Verlegung von einem Element In dem Fall, dass beide Nachbarn von S j ebenfalls keine Elemente mehr aufnehmen können, wird S i in zwei gleich große Blöcke S i1 und S i2 aufgeteilt, die beide etwa B/2- Elemente besitzen (siehe Abbildung 11). Durch das Aufteilen wird die Invariante nicht verletzt, da der Vorgängerblock von S i1 sowie der Nachfolgeblock von S i2 jeweils B Elemente besitzen und die Summe der Elemente in beiden Blöcken somit etwa B + B 2 entspricht. Die Invariante gilt zudem für S i1 und S i2, da diese dadurch entstanden, dass S i mehr als B Elemente aufnehmen sollte. Abbildung 11: Einfügen mit Aufteilen des Blockes

21 17 In allen Fällen werden nur eine konstante Anzahl von I/O-Operationen für den betroffenen Block S i und seine direkten Nachbarblöcke aufgebracht. Sind beide Nachbarblöcke voll, so wird S i gesplittet, um eine rekursive Bearbeitung der Speicherblöcke zu vermeiden. Das Löschen eines Elementes erfolgt mit Hilfe einer I/O-Operation, falls die Invariante nicht verletzt wird. Nach dem Löschen eines Elementes aus Block S i wird überprüft, ob die Summe der Elemente mit beiden Nachbarn jeweils 2 B Elemente übersteigt. Ist 3 die Invariante mit einem Blocknachbarn S j nicht erfüllt, werden die Elemente beider Blöcke in einem Speicherblock S i zusammengefasst. Da nun S i mehr Elemente besitzt als S j und S i einzeln, sind die Invarianten mit den neuen Nachbarn von S i erfüllt. Das Zusammenfassen der Speicherblöcke zu einem einzigen Speicherblock ist möglich, da dies nur erfolgt, wenn beide Blöcke zusammen nicht mehr als 2 B Elemente besitzen (welches 3 kleiner als B ist). Beim Löschen oder Einfügen in eine verkettete Liste kann es erforderlich sein, Speicherblöcke zu splitten oder zusammenzufassen. Da die Elemente zu einer Liste mit einer geordneten Reihenfolge gehören, kann diese Operation in O(1) I/O-Operationen erfolgen. Bei der Verknüpfung von zwei Speicherblöcken S i und S i+1 werden die Daten aus S i+1 in den Speicher gelesen und anschließend in den Block S i geschrieben. Da S i+1 weniger als B Elemente besitzt, können alle Daten mit jeweils einer I/O-Operation gelesen und geschrieben werden. Die Anpassung der Pointer der Nachbarblöcke S i 1 und S i+2 ist nicht erforderlich. Das Aufteilen eines Blockes S i in zwei Speicherblöcke S i1 und S i2 beginnt mit dem Auslesen der größten B/2 Elemente von S i und der Abspeicherung in einem neuen Speicherblock S i2. Nach der Belegung des neuen Speicherblocks bedarf es noch der Anpassung des Pointers vom alten Block S i auf S i2. Laufzeitanalyse 3.3. Das Einfügen oder Löschen eines Elementes in der verketteten Liste inklusive gegebenenfalls der Wiederherstellung der Invariante ist mit einer konstanten Anzahl von I/O- Operationen möglich, da höchstens der betroffene Block sowie seine direkten Nachbarn angepasst werden müssen.

22 Wörterbuch Ein Wörterbuch ist eine abstrakte Datenstruktur, welches auf Anfragen nach einem Schlüssel k einen zugehörigen Wert ausgibt. Diese Anfragen werden Lookup genannt. Die Schlüssel gehören zu einer begrenzten Schlüsselmenge K. Dynamische Veränderungen (Einfügen und Löschen) des Datenbestandes eines Wörterbuchs sind oftmals erwünscht. Für die Implementierung von Wörterbüchern können entweder B-Bäume oder Hashing verwendet werden. Wörterbücher, die Hashing verwenden, können die elementaren Anfragen im Wörterbuch wie Einfügen, Löschen und Lookup, in O(1) I/O-Operationen durchführen [DKM + 94]. B-Bäume verlangen zwar eine Gesamtordnung der Schlüsselwerte und haben eine schlechtere Performance bei den elementaren Operationen, ermöglichen jedoch, dass neben dem Lookup andere Anfragen effizient beantwortet werden können. Details werden dazu in Kapitel 4 gegeben. Wörterbücher sind eine effiziente Datenstruktur zur Verwaltung von vielen Anwendungen wie zum Beispiel von virtuellem Speicher oder von robusten Zeigern. Der folgende Abschnitt stellt die Nutzung eines Wörterbuches für die Verwaltung vom virtuellem Speicher vor. Die Verwendung für die robuste Speicherung von Zeigern wird in [MSS03, S.18f] erläutert Anwendungsgebiet Virtueller Speicher In Algorithmen, die Arrays im externen Speicher verwenden, erfolgt oftmals eine Nutzung und Freigabe von Bereichen von Speicherblöcken. Wie auch im internen Speicher kann aus dieser dynamischen Nutzung eine Zerstückelung sowie eine schlechte Nutzbarkeit des Speichers resultieren. Im externen Speicher kann es soweit gehen, dass zusammengehörige Speicherblöcke nicht mehr aufeinanderfolgend allokiert werden können und folglich für den Datenzugriff eine längere Bearbeitungszeit erforderlich ist. Falls ein konstanter Faktor an zusätzlichen I/Os akzeptiert wird, kann mit Hilfe von Wörterbüchern ein virtueller Speicher realisiert werden. Dazu wird die Schlüsselmenge K = {1,..., C} {1,..., L} definiert, wobei C die maximale Anzahl unterschiedlicher Arrays ist und L die maximale Länge eines Arrays. Mit dem Schlüssel (c, i) wird das i-te Element des c-ten Arrays geliefert, falls dieses zuvor geschrieben wurde. Benötigt die Implementierung des Wörterbuches eine (durchschnittlich) konstante Anzahl von I/O-Operationen für das Einfügen, Löschen und Lookup, wird die Verwaltung

23 19 des virtuellen Speichers lediglich um eine konstante Anzahl von I/O-Operationen erhöht. Für eine detaillierte Beschreibung der Realisierung anhand des Wörterbuches wird auf [MSS03, S.18] verwiesen.

24 20 4 B-Bäume Bäume sind eine geeignete Datenstruktur, um Elemente zu verwalten, die einer totalen Ordnung unterliegen. Die Daten werden in den Blättern gespeichert und die internen Knoten dienen dem Auffinden der Elemente. In diesem Kapitel ist N die Größe der Schlüsselmenge und B die Anzahl der Schlüssel oder Zeiger, die maximal in einen Speicherblock abgelegt werden können. Abbildung 12: Nutzung von Speicherblöcken beim Binärbaum Aus zwei Gründen eignen sich Binärbäume nicht für die Nutzung im externen Speicher. Zum einen ist das Datenvolumen eines Knotens zu gering im Vergleich zur Blockgröße. Da die internen Knoten nur zwei Ausgänge und einen Schlüssel besitzen, sind entweder die Speicherblöcke nicht vollkommen belegt oder die Informationen eines Knotens werden in verschiedenen Speicherblöcken gesichert (siehe Abbildung 12). Zum anderen haben Knoten in einem Binärbaum nur zwei Kinder und damit eine Tiefe von O(log N), welches mit der Anzahl von I/O-Operationen übereinstimmt, die für die Suche eines Elementes x aufgebracht werden müssen. Idealerweise wird der Ausgangsgrad des Knoten so erhöht, dass ein Speicherblock durch genau einen Knoten ausgefüllt wird. Damit ergeben sich je nach Blockgröße sehr flache Bäume und es werden Ω(log B N) I/O-Operationen für die Suche nach einem Element x benötigt. B-Bäume sind eine Verallgemeinerung der binären Suchbäume, welche einen Ausgangsgrad von Θ(B) Kindern besitzen [BM72, Com79, HM82, Knu73, RL90] und eignen sich für die Realisierung im externen Speicher. Der folgende Abschnitt stellt das Konzept von gewichteten B-Bäumen vor sowie einige Eigenschaften, welche bedeutend für die Laufzeitanalyse der Operationen sind. Die elementaren Operationen im gewichteten B-Baum werden in Abschnitt 4.2 vorgestellt.

25 21 Eine Effizienzsteigerung kann durch Buffer Bäume erreicht werden, wenn eine verzögerte Antwortzeit erlaubt ist (siehe Abschnitt 4.3). Ein Überblick über weitere Varianten von B-Bäumen wird in Abschnitt 4.4 gegeben. 4.1 Konzept der gewichteten B-Bäume Für die Vorstellung und Analyse von gewichteten B-Bäumen soll folgende Annahme erfüllt sein. Sie dient lediglich der Vereinfachung von Abschätzungen und aus ihr resultiert keine Beeinschränkung der Allgemeinheit. Annahme 4.1. Es gelte, dass B eine ganze Zahl ist, mit B 4. Folglich soll die Kapazität eines Speicherblock mindestens 32 Schlüssel oder Zeiger fassen können. 8 8 Gewichtete B-Bäume sind eine spezielle Art von B-Bäumen, deren Balancierungsinvariante auf der Anzahl der einem Knoten untergeordneten Blätter beruht. Dieses Konzept wurde von Arge und Vitter [AV96] für B-Bäume eingeführt, um die Anzahl von Rebalancierungen gegenüber Invarianten des Augangsgrades zu reduzieren. Sie forderten, dass eine Rebalancierung eines Knotens v mit Gewicht w(v) nur alle Ω (w(v)) Updateoperationen von darunterliegenden Elementen notwendig sein soll [Arg04]. Jedoch haben gewöhnliche B-Bäume oder auch (a, b)-bäume diese Eigenschaft nicht und sie entwickelten die gewichteten B-Bäume. Im Vergleich zu den originalen B-Bäumen sind einige Eigenschaften bewahrt und einige verschärft worden. In gewichteten B-Bäumen haben - wie auch in originalen B-Bäumen - alle Blätter die gleiche Distanz zum Wurzelknoten, welches der Höhe des Baumes entspricht. Des Weiteren hat die Wurzel eine Sonderstellung und darf einen beliebigen Ausgangsgrad größer zwei besitzen. Darüber hinaus werden folgende Begriffe definiert: Definition 4.1. Das Level eines Knotens i im B-Baum ist die Distanz zu den Blättern seiner untergeordneten Teilbäume. Da alle Blätter die gleiche Distanz zur Wurzel haben, ist der Wert eindeutig definiert. Die Anzahl der Blätter in den untergeordneten Teilbäumen wird Gewicht des Knotens i genannt.

26 22 Abbildung 13: Gewichteter B-Baum Blätter befinden sich auf dem Level Null und haben Gewicht Eins. Da jeder interne Knoten (bis auf die Wurzel) Θ(B) Kinder besitzt, wird in einem internen Knoten eine Suchanfrage unter Nutzung der totalen Ordnung der Schlüssel in einen der Θ(B) Teilbäume weitergeleitet. Ein interner Knoten hält somit eine Menge von Schlüsseln, um die Suche in den korrekten Teilbäumen ermöglichen zu können (siehe Abbildung 13). Die Anzahl der Blätter, die sich unter einem Knoten eines niedrigeren Levels befinden, sinkt folglich um den Faktor Θ(B). Diese Eigenschaft wurde in einer Gewichtsinvariante für die Balancierung genutzt: Definition 4.2. [AV96] Jeder Knoten mit Level i - außer der Wurzel - hat ein Gewicht von mindestens (B/8) i. 3 Eine maximale Gewichtsbeschränkung von 4(B/8) i gilt für jeden beliebigen Knoten des gewichteten B-Baums. Die sich aus dieser Gewichtsinvariante ergebenen Eigenschaften werden im folgenden Abschnitt beschrieben Eigenschaften Dieser Abschnitt präsentiert die Eigenschaften eines gewichteten B-Baums, welcher die Gewichtsinvariante aus Defintion 4.2 erfüllt. Zunächst wird die minimale und maximale Anzahl von Kindern eines Knotens untersucht, anschließend erfolgt die Berechnung der Höhe des gewichteten B-Baumes. Die Berechnungen der Kinderanzahl folgen analog zu [Arg04]. 1. Maximale Kinderanzahl eines beliebigen Knotens Ein Knoten a auf Level i hat den höchsten Ausgangsgrad, wenn der Knoten a das 3 Dies gilt für alle Knoten mit Level i < h, wobei h die Höhe des Baumes ist

27 23 Maximalgewicht von 4(B/8) i und jedes Kind minimales Gewicht (B/8) i 1 besitzt. Es gilt, dass das Gewicht von Knoten a der Summe der Gewichte seiner Kinder entspricht. Damit folgt: ) i 4 ( B 8 ) i 1 = 4 B 8 = B 2 ( B 8 (4) 2. Minimale Kinderanzahl eines beliebigen Knotens (außer der Wurzel) Ein Knoten a auf Level i hat den kleinsten Ausgangsgrad, wenn der Knoten a das minimale Gewicht von (B/8) i besitzt und jedes Kind das Maximalgewicht von 4(B/8) i 1 angenommen hat. Damit folgt: ( B ) i 8 4 ( ) B i 1 = 1 4 B 8 = B 32 8 (5) 3. Maximale Höhe eines gewichteten B-Baums Die Wurzel hat beim gewichteten B-Baum wie auch beim gewöhnlichen B-Baum eine Sonderstellung und darf auch weniger als B Kinder besitzen. Folglich wird bei der Berechnung der Höhe die Wurzel zunächst außer Betracht gelassen und anschließend um 32 Eins inkrementiert. Der gewichtete B-Baum erreicht die größte Höhe, wenn jeder Knoten Minimalgewicht besitzt. Hat der Knoten a auf Level i das Minimalgewicht von (B/8) i und alle seine Kinder haben ebenfalls minimales Gewicht (B/8) i 1, so ergibt sich für den Ausgangsgrad: ( B 8 ) i ( B 8 ) i 1 = B 8 (6) Hat jeder Knoten B Kinder, so besitzt der Baum inklusive der Wurzel höchstens log B/8 N Ebenen (= Höhe des Baumes). 4. Untere Grenze für Baumhöhe eines gewichteten B-Baums Der gewichtete B-Baum soll einen Ausgangsgrad von Θ(B) besitzen. Hat jeder Knoten im Baum B Kinder, so ist die Höhe des Baumes log B N. Laut (4) ist jedoch die maximale

28 24 Anzahl von Kindern B. Eine eine untere Grenze für die Baumhöhe ist demnach 2 Ω(log B N) (7) Folgerung 4.1. Die Anzahl der untergeordneten Teilbäume eines jeden Knotens, abgesehen von der Wurzel, liegt im Interval [ B, ] B Hat ein Knoten B Kinder, so ist es erforderlich für diesen 2 Knoten B Zeiger zu den Teilbäumen sowie B 1 Schlüssel für die Navigation im Baum 2 2 abzuspeichern. Zusätzlich wird die Anzahl der untergeordneten Blätter gesichert, um die Invariante effizient überprüfen zu können. Der Speicherplatz für diese Daten beträgt insgesamt: B }{{} 2 + B 2 1 } {{ } Zeiger Schlüssel Zähler + 1 }{{} = B (8) Wie zuvor erwähnt, werden die Daten mit ihren Schlüsseln in den Blättern gespeichert. Diese Blätter sind als verkettete Liste verknüpft wie in Abbildung 14 dargestellt. Die Verkettung ermöglicht die effiziente Bearbeitung von Anfragen wie Range Reporting, welches in Abschnitt vorgestellt wird. Es gilt zu beachten, dass in einem Speicherblock der verketteten Liste weniger als Θ(B) Elemente enthalten sein können, falls die eigentlichen Daten des Wörterbuches mehr Platz benötigen als ihre Schlüssel. Schließlich bestimmt B die Anzahl von Schlüsseln oder Zeigern, die in einen Speicherblock gespeichert sein können, und stellt keine Beziehung zu der eigentlichen Datengröße dar. Abbildung 14: Blätter im B-Baum als verkettete Liste

29 Operationen im gewichteten B-Baum Dieser Abschnitt stellt die Funktionsweisen der elementaren Operationen Suchen, Einfügen und Löschen vor. Die Operationen werden mit Hilfe der Eigenschaften aus Abschnitt bezüglich ihrer Laufzeit analysiert. Darüber hinaus wird auf das Range Reporting eingegangen (siehe Abschnitt 4.2.4), welches eine spezielle Datenanfrage ist und effizient im B-Baum durchgeführt werden kann. Diese Operation zeigt beispielhaft, dass B-Bäume auch bei einer schlechteren Leistung bei den elementaren Operationen gegenüber dem Hashing für die Realisierung von Wörterbüchern vorteilhaft sind, falls weitere Anfragetypen unterstützt werden sollen Suchen Die internen Knoten eines B-Baumes speichern abhängig von der Anzahl ihrer Kinder eine Menge von Schlüsseln für die Navigation im Baum. Ein Knoten v hält die Schlüssel k 1,..., k dv 1, wobei der i-te Teilbaum die Schlüssel k mit k i 1 k < k i abspeichert. Dabei seien k 0 = und k dv = definiert. Beispiel 4.1. Im Beispielbaum der Abbildung 14 hat die Wurzel zwei Teilbäume und hält den Schlüssel k 1. Die Schlüssel im linken Teilbaum sind alle kleiner als k 1. Der Schlüssel k 2 gehört zum zweiten Datenelement und der Schlüssel k 3 zum dritten Datenelement. Der rechte Teilbaum beinhaltet das Element, welches zum Schlüssel k 1 gehört. Im Detail gehört k 1 zu dem kleinsten Element des rechten Teilbaums, welches das Datenelement 4 ist. Da sich alle Daten in den Blättern des Baumes befinden und jeder interne Knoten in einem eigenen Speicherblock gesichert wird, entspricht die Anzahl der I/O-Operationen für eine Suche nicht nur im worst case sondern auch im best und average case der Baumhöhe. Gemäß (6) ist die maximale Baumhöhe 1 + log B/8 N. Der Binärbaum hat eine Höhe von O(log N), folglich wird durch die Nutzung eines gewichteten B-Baums etwa ein Faktor von log B I/O-Operationen gespart. Das folgende Beispiel 4.2 soll veranschaulichen, dass bei realistischen Größen von B und N die Anzahl der I/O-Operationen gering sind. Beispiel 4.2. Ist B = 2 12 und N 2 27 ist die Höhe des Baumes begrenzt durch 4. Da die Wurzel

30 26 im internen Speicher gesichert werden kann, benötigt eine Suche somit nur drei I/O- Operationen, um eins von maximal 2 27 Elementen zu finden Einfügen Das Einfügen im gewichteten B-Baum entspricht der Methode im Binärbaum außer wenn die Invariante verletzt wird und eine Rebalancierung notwendig ist. Das Einfügen wird in diesem Abschnitt mit Hilfe eines Beispieles verdeutlicht, in welchem das Datenelement 7 eingefügt werden soll (Abbildungen 15-17). Abbildung 15: Auffinden der korrekten Position Für das Einfügen eines Elementes x, wird zunächst nach x gesucht, um den internen Knoten zu finden, welcher der Elternknoten von x werden soll. Wie in Abbildung 15 dargestellt, wird im Beispielbaum für das Datenelement 7 der rechte Teilbaum der Wurzel als Elternknoten gefunden. Der Elternknoten erhält ein weiteres Blatt für das Datenelement 7, welches die Aufnahme eines weiteren Schlüssels (k 8 ) einschließt (siehe Abbildung 16). Abbildung 16: Erzeugung eines neuen Blattes und Modifikation des Elternknotens

31 27 Im Beispielbaum sei für das Level 1 ein Gewicht zwischen 3 und 5 erlaubt. Dieses stimmt zwar nicht mit der Gewichtsinvariante aus Definition 4.2 unter der Annahme 4.1 überein. Da die Komplexität des Beispiels jedoch anschaulich bleiben soll, wurde die Gewichtsinvariante auf diese Art vereinfacht, um das Prinzip der Rebalancierung zu erläutern. Gemäß des erlaubten Gewichtes im Intervall [3, 5], ist es erforderlich nach dem Einfügen des Datenelementes 7 den rechten Teilbaum zu rebalancieren. Die Rebalancierung beginnt von unten nach oben und teilt alle übergewichtigen Knoten in zwei neue Knoten auf. Es sei angemerkt, dass durch das Einfügen bei einem Knoten v auf Level i sich das Gewicht auf 4 ( B 8 ) i + 1 erhöht, falls er das zulässige Maximalgewicht überschritten hat. Um einen Knoten v in zwei neue Knoten v 1 und v 2 aufzuteilen, werden die Kinder von v in zwei Gruppen eingeteilt. Habe v die Schlüssel k 1,... k dv, dann erfolgt die Gruppenaufteilung auf Basis eines Schlüssels k {k 1,... k dv } so dass k i des Teilbaumes von v 1 k i < k gilt und k j des Teilbaumes von v 2 k j k erfüllt ist. Diese Aufteilung ist erforderlich, um die totale Ordnung im Baum auch mit den neuen Knoten zu gewährleisten. Nach der Aufteilung des Knoten v wird dieser durch die beiden neuen Knoten ersetzt und der Schlüssel k in den Elternknoten aufgenommen (siehe Abbildung 17). Ist die Gewichtsinvariante des Elternknotens verletzt, wird auch dieser in zwei Knoten aufgeteilt. Die Überprüfung auf die Gültigkeit der Gewichtsinvariante wird gestoppt, sobald bei einem Elternknoten die Invariante nicht mehr verletzt ist. Abbildung 17: B-Baum nach Rebalancierung des rechten Teilbaums Ziel bei der Bildung der neuen Knoten v 1 und v 2 ist es, einen möglichst balancierten Baum zu erhalten, damit nach Möglichkeit sehr wenige Rebalancierungen bei weiteren Einfüge- oder Löschoperationen notwendig sind. Folglich sollen die Gewichte der Knoten v 1 und v 2 weitestgehend identisch sein. Jedoch dürfen bereits existierende Teilbäume nicht gesplittet werden und daher ist oftmals die Erzeugung gleichschwerer Knoten v 1 und v 2 nicht möglich. Da ein untergeordneter Teilbaum von v maxi-

32 28 mal Gewicht 4 ( ) B i 1 8 besitzt, können v1 und v 2 höchstens um 2 ( ) B i 1 8 Gewichtseinheiten [ differieren. Damit befinden sich die Gewichte der neuen Knoten im Interval 2 ( ) B i ( 8 2 B ) i 1 ( 8, 2 B ) i ( B ) ] i Da nach Annahme 4.1 B/8 4 ist, haben ( v 1 und v 2 Gewichte im Bereich 3 B ) i ( 2 8 und 5 B ) i , wie die folgenden Berechnungen ( zeigen. Die untere Grenze von 3 B ) i 2 8 für Knoten v1 und v 2 ist gültig für B 4: ( B 8 ( B 8 ( B 8 ( ) ) i ( ) i 1 i B B ( ) ) i ( ) i 1 i B B 8 8 ( ) ) B 1 (9) 8 gültig für B 8 4 Die obere Grenze von 5 2 ( B 8 ) i + 1 für Knoten v1 und v 2 ist gültig für B 8 4: ( B 8 ( ) ) i ( ) i 1 i B B ( ) ) i ( ) i 1 i B B ( ) ) B + 1 (10) 8 ( B 8 ( B 8 gültig für B 8 4 ( Mit (9) und (10) wurde gezeigt, dass die neuen Knoten ein Gewicht zwischen 3 B ) i 2 8 und ( 5 B ) i+1 ( 2 8 besitzen und somit Ω ( B )i) Gewichtseinheiten vom minimalen und maximalen 8 Gewicht differieren. Diese Beobachtung wird benötigt, um die Anzahl der Einfüge- und Löschoperationen abzuschätzen, nach welchen eine Rebalancierung der neuen Knoten v 1 und v 2 erforderlich ist. Laufzeitanalyse 4.1. Die erforderlichen I/O-Operationen einer Rebalancierung für einen Knoten v sind kostant, da nur eine konstante Anzahl an internen Knoten betroffen sind (Knoten v, Elternknoten von v sowie die neuen Knoten v 1 und v 2 ). Mit der vorhergehenden Suchope-

33 29 ration benötigt das Einfügen somit O(log B N)- I/O-Operationen. [MSS03, S.22] zeigt, dass amortisierte Kosten von lediglich O ( 1 B log B N ) I/Os pro Änderungsoperation im Baum benötigt werden. Genutzt wird bei dieser Berechnung, dass nach einer Rebalancierung die neuen Knoten Ω ( ( B 8 )i) Gewichtseinheiten entfernt von der minimalen und der maximalen Gewichtsgrenze sind Löschen Die Durchführung einer Löschoperation ist der Einfügeoperation sehr ähnlich. Das zu löschende Element x wird gesucht, das entsprechende Blatt gelöscht und der Elternknoten v modifiziert. Der neue Elternknoten v besitzt ein Kind und einen Schlüssel weniger als vor der Löschoperation. Da sich das Gewicht des Elternknotens v um Eins reduziert, B 8 kann die untere Gewichtsgrenze von B verletzt werden. Diese Gewichtsgrenze ist fest auf 8 gesetzt, da Elternknoten eines Blattes sich auf dem Level Eins befinden. Durch die Löschoperation können jedoch die Gewichtsinvarianten mehrerer Knoten auf dem Pfad zur Wurzel verletzt worden sein, so dass für die Rebalancierung allgemein ein Knoten v auf Level i betrachtet wird. Wie beim Einfügen werden Rebalancierungsoperationen beim Knoten des unteresten Level gestartet und, falls notwendig, auf dem Pfad zur Wurzel fortgesetzt. Die Rebalancierung eines Knotens v erfolgt mit Hilfe einer seiner direkten Nachbarn w. Da alle Blätter sich auf dem gleichen Level befinden, existiert für einen internen Knoten v ein anderer interner Knoten w als Nachbar. Für diesen Nachbarn ist die Gewichtsinvariante erfüllt, da seine Kinder nicht durch die Löschoperation verändert wurden. Für die Rebalancierung ist es notwendig, zwei Fälle für die Summe S der Gewichte von v und w zu betrachten: 1. S 7 2 ( B 8 ) i ( 7 2. B ) i ( 2 8 < S < 5 B ) i 8 Die Rebalancierung des ersten Falles wird mit Hilfe der Abbildungen 18 und 19 veranschaulicht. Diese Abbildungen dienen lediglich dem Verständnis der Rebalancierung und genügen nicht der definierten Gewichtsinvariante aus Definition 4.2 mit Annahme 4.1. In den Beispielabbildungen wird die Gewichtsinvariante erfüllt, wenn Knoten auf Level 1 ein Gewicht zwischen 3 und 5 besitzen. Abbildung 18 zeigt einen Beispielbaum vor dem Löschen von dem Datenelement 3, in welchem der erste Fall angewendet wird.

34 30 Die Löschung des Datenelementes 3 im zweiten Fall wird an Hand von Abbildung 20 dargestellt. Abbildung 18: 1. Fall: B-Baum vor dem Löschen des Datenelementes 3 Im ersten Fall werden die beiden Knoten v und w zu einem Knoten v verschmolzen. Falls v und w die einzigen Kinder der Wurzel waren, wird v die neue Wurzel des gewichteten B-Baumes. Das Zusammenfügen zu einem neuen Knoten v ist effizient realisierbar, da lediglich ein neuer Schlüssel in v für den ersten bzw. letzten Teilbaum von w aufgenommen werden muss und anschließend die restlichen Schlüssel sowie die Zeiger von w übernommen werden. Abbildung 19: Zusammengefügte Knoten nach der Rebalancierung ( Möglich wäre es, für den ersten Fall nicht S 7 B ) i 2 8 sondern gemäß der Gewichtsinvariante S 4 ( ) B i 8 zu definieren. Jedoch würde dann eine einzige Einfügeoperation in einen Teilbaum von v in einer erneuten Rebalancierung resultieren. Aus diesem Grund ( wird die maximale Grenze des gemeinsamen Gewichtes von v und w auf 7 B ) i 2 8 gesetzt, welches wiederum wie beim Einfügen um Ω((B/8) i ) Gewichtseinheiten von den Grenzen der Gewichtsinvariante differiert.

35 31 Abbildung 20: 2. Fall: B-Baum vor dem Löschen des Datenelementes 3 Das gemeinsame Gewicht von v und w ist nach oben begrenzt durch 5 ( B 8 ) i, da v untergewichtig ist. Dieses maximale Gewicht wird angenommen, wenn Knoten w Maximalgewicht 4 ( B 8 ) i besitzt. Im Rahmen der Rebalancierung im zweiten Fall werden einige Kinder von w dem Knoten v übertragen. Wie beim Einfügen wird dabei das Ziel verfolgt, einen möglichst balancierten Baum zu erreichen. Folglich werden alle Kinder von v und w in zwei aufeinanderfolgende Gruppen aufgeteilt. Da ein Kind höchstens Gewicht 4 ( ) B i 1 ( 8 besitzen kann, sind die Gewichte der Gruppen begrenzt durch 7 B ) i ( B ) i 1 8 ( sowie 5 B ) i ( B ) i 1. 8 Die Kinder der einen Gruppe werden dem neuen Knoten w und die anderen Kinder dem neuen Knoten v zugeordnet (siehe Abbildung 21). Auch nach dieser Rebalancierung unterscheiden sich die Gewichte der neuen Knoten um Ω((B/8) i ) Gewichtseinheiten von den Grenzen der Gewichtsinvariante. Abbildung 21: Aufgeteilte Kinder zwischen erstem und zweitem Teilbaum Laufzeitanalyse 4.2. Analog zur Laufzeitanalyse 4.1 benötigt die Rebalancierung O(1) I/O-Operationen und der gesamte Löschvorgang inklusive der vorhergehenden Suchoperation O(log B N) I/O- Operationen. Die amortisierten Kosten pro Änderungsoperation sind O ( 1 log B B N ) I/Os.

36 Range Reporting B-Bäume eignen sich als Implementierung eines Wörterbuches, da sie neben Lookup- Anfragen auch weitere Anfragetypen effizient bearbeiten können. In diesem Abschnitt wird das Range Reporting vorgestellt, welches die Daten in einem Bereich [a, b] zurückliefert. Die Bearbeitung einer Range Anfrage nach allen Daten mit Schlüsseln im Intervall [a, b] beginnt zunächst mit der Suche des Schlüssels a im B-Baum. Da die Blätter im B-Baum über eine verkettete Liste verbunden sind, erfolgt ein sequentieller Durchlauf der Liste bis der Schlüssel b und sein zugehöriges Datenelement erreicht werden. Beim Durchlauf werden alle Schlüssel mit zugehörigen Daten ausgegeben, so dass die Bereichsanfrage beantwortet ist. Die Anzahl der I/O-Operationen für ein Range Reporting ist zum einen abhängig von der Größe der gesicherten Datenelemente und zum anderen von der Schlüsselanzahl im Bereich [a, b]. Sollen im B-Baum keine Nutzdaten und lediglich die Schlüssel abgespeichert werden und es befinden sich Z Schlüssel im Bereich [a, b], so erfordert der Listendurchlauf O ( ) Z B I/O-Operationen. Für die gesamte Bearbeitung des Range Reportings kommen zudem O(log B N) I/Os für die Suche des Schlüssels a hinzu. 4.3 Buffer Bäume Buffer Bäume [Arg95] können verwendet werden, wenn Anfragen mit einer gewissen Verzögerungszeit und nicht online beantwortet werden dürfen. Bei der Stapelverarbeitung von Anfragen und Updateoperationen ist diese Voraussetzung zum Beispiel gegeben. Erst nachdem alle Anfragen und Operationen des Stapels durchgeführt wurden, werden die Antworten der Anfragen bereitgestellt. Abschnitt stellt das grobe Konzept der Buffer Bäume vor. Die Datenstruktur eignet sich vor allem für eine effiziente Realisierung der Priority-Queue, welches im Abschnitt erläutert wird. In beiden Abschnitten wird vorausgesetzt, dass die abzuspeichernden Werte die gleiche Größe wie die Schlüssel besitzen.

37 Konzept Buffer Bäume sind gewöhnliche B-Bäume mit Ausgangsgrad Θ(M/B). Jeder interne Knoten des Baumes ist mit einer Queue ausgestattet, in welcher die letzten M Updateoperationen und Anfragen gesichert sind, die auf einen der Teilbäume unter dem internen Knoten ausgeführt werden müssen. Es ist wichtig, die Methoden in einer Queue zu sichern, um ihre Bearbeitung in der korrekten Reihenfolge ausführen zu können. Die Wurzel wird aus dem internen Speicher schubweise über die durchzuführenden Operationen informiert. Dies geschieht durch eine Datenhaltung im internen Speicher, so dass das Schreiben in den Wurzelknoten pro Methode durchschnittlich O(1/B) I/O-Operationen kostet. Ist der Buffer des (Wurzel-)knotens v voll, so wird dieser ausgeschüttet (engl. flushed): Der Inhalt der Queue von v wird in den internen Speicher geladen und gemäß der verantwortlichen Teilbäume sortiert. Anschließend werden die zugehörigen Methoden in die Buffer jedes Kindes des ausgeschütteten Knotens v geschrieben. Ist durch diese Zuweisung die Bufferkapazität des Kindknotens ausgeschöpft, so wird dieser anschließend ausgeschüttet usw. Wird ein Knoten v geflusht, dessen Kinder Blätter des B-Baumes sind, so werden seine M/B Kinder in den internen Speicher geladen und die auszuführenden Methoden (Anfragen und Updateoperationen) nach und nach durchgeführt. Nach der kompletten Verarbeitung aller Methoden kann die Invariante des Knotens v verletzt sein, so dass eine Rebalancierung notwendig ist. Wird der einem Blatt übergeordnete Knoten v geflusht, ist dies eine Folge der Ausschüttung des Elternknotens von v usw. Somit existiert eine sequentielle Ausschüttung der Knoten auf dem Pfad zwischen Wurzel und Blatt, falls alle Buffer voll werden, und alle in einer Rebalancierung involvierten Knoten haben einen leeren Buffer. Folglich muss der Buffer bei der Rebalancierung nicht betrachtet werden und die Rebalancierung eines Buffer Baums entspricht der Vorgehensweise eines gewöhnlichen B-Baums. Für die Laufzeiten wird auf eine detaillierte Analyse verzichtet, da lediglich die Idee der Buffer Bäume vorgestellt werden soll. Laufzeitanalyse 4.3. Ein Ausschütten kostet O(1/B) I/O-Operationen pro Methode, die im Buffer gehalten wird. Da alle Operationen an den Blättern durchgeführt werden, ( entspricht die Anzahl ( der benötigten Ausschüttungen pro Methode der Baumhöhe O log N ) ) M. Die Gesamtkosten belaufen sich damit auf O log ( N B M ( 1 ) ) B M I/O-Operationen pro auszuführende Me- B M

38 34 thode. Das Rebalancieren eines Knotens benötigt O(M/B) I/O-Operationen. Die erforderlichen Rebalancierungen bei N Updates kosten jedoch zusammen nur O(N/B) I/O- Operationen, da bei N Updates nur O(N/M) Rebalancierungen notwendig sind [HM82] Realisierung der Priority-Queue Daten einer Priority-Queue sind gemäß eines Schlüssels sortiert, welcher mit ihrer Priorität übereinstimmt. Elementare Operationen sind das Einfügen eines Schlüssels mit zugehörigen Daten und die Suche und Löschung des kleinsten Schlüssels. 4 Die effizienteste Realisierung einer Priority-Queue erfolgt mit einem Buffer Baum, falls O(M) interner Speicher zu Verfügung steht. Der interne Speicher beinhaltet zum einen den Buffer des Wurzelknotens. Zum anderen werden die linkesten O ( M B ) Blätter gespeichert, welche den Kindern des linkesten internen Knotens v entsprechen. Als Invariante wird definiert, dass alle Buffer auf dem Pfad von der Wurzel zum linkesten Knoten v leer sind. Dies geht damit einher, dass beim Ausschütten der Wurzel nach einander jeweils das linkeste Kind auf jedem Level geflusht wird. Zur Erfüllung der Invariante werden zusätzliche Flushes benötigt, die aufgrund des Füllstandes der ( Buffer nicht 1 unbedingt erforderlich wären. Sie belaufen sich auf durchschnittlich O log ( N ) ) B M B M I/O-Operationen pro Updateoperation im Baum. Abbildung 22: Priority-Queue nach Löschung der M kleinsten Elemente mit M = 5 4 Stacks und Queues sind spezielle Fälle der Priority-Queue.

39 35 Da im internen Speicher die linkesten O ( M B ) Blätter gesichert sind, befinden sich im internen Speicher die M kleinsten Elemente der Datenstruktur. Das Auslesen des kleinsten Elementes benötigt somit keine I/O-Operation. Werden die M kleinsten Elemente gelöscht, sind mindestens M Operationen erforderlich. Zwar könnte der interne Speicher in diesem Fall das Lesen des kleinsten Elementes nicht mehr ohne eine I/O-Operation durchführen, aber nach M Operationen wird der Buffer der Wurzel ausgeschüttet. Daraus folgt, dass der linkeste Pfad des B-Baumes vollständig aktualisiert wird und anschließend die neuen M kleinsten Elemente im internen Speicher vorhanden sind. Abbildung 22 zeigt eine Priority-Queue mit M = 5, in welcher gerade der Buffer ausgeschüttet wurde. In diesem Beispiel wurden hintereinander alle kleinsten Elemente der Priority-Queue gelöscht, so dass der linkeste Teilbaum vollständig verschwunden ist und die neuen fünf kleinsten Elemente in den internen Speicher geladen wurden. Laufzeitanalyse 4.4. Eine Priority-Queue ( realisiert mit einem Buffer Baum benötigt eine amortisierte Laufzeit von O log ( N 1 ) ) B M I/O-Operationen für das Einfügen und Löschen eines beliebigen Elementes. Das kleinste Element wird redundant im internen Speicher B M gehalten, so dass Leseoperationen ohne I/O-Operationen durchgeführt werden können. Da es sich jedoch nur um eine redundante Kopie des kleinsten Elementes handelt, ist das Löschen des kleinsten Elementes genauso aufwendig wie für alle anderen Elemente. 4.4 Varianten von B-Bäumen In diesem Abschnitt wird ein Überblick über weitere Varianten von originalen B-Bäumen gegeben. Jeder Abschnitt gibt einen Überblick der Modifikationsidee. Abschnitt betrachtet zusätzliche Zeiger, Abschnitt Persistenz und Abschnitt unbeschränkte Stringgrößen.

40 B-Bäume mit zustätzlichen Zeigern In gewöhnlichen B-Bäumen können zusätzliche Zeiger hinzugefügt werden, um spezielle Anfragen effizient bearbeiten zu können (siehe Abbildung 23). Die Aktualisierung von Zeigern auf den Elternknoten kann ohne zusätzliche Kosten durchgeführt werden. Abbildung 23: B-Baum mit Zeigern auf Elternknoten Des Weiteren können Zeiger auf Nachbarknoten des gleichen Levels eingefügt werden, wodurch eine doppelt verketteten Liste entsteht (siehe Abbildung 24). Derartige zusätzliche Zeiger ermöglichen so genannte finger-suchen effizient zu beantworten. In einer finger-suche wird von einem Blatt v aus in einem B-Baum ein anderes Blatt w gesucht. Bei B-Bäumen mit zusätzlichen Zeigern auf die Elternknoten sowie auf die Nachbarknoten kann der Suchalgorithmus von Knoten v den Pfad zur Wurzel verfolgen bis der aktuelle Knoten oder einer seiner Nachbarknoten das Blatt w in einem seiner Teilbäume hat. Zum Finden des Blattes w bedarf es dann der gewöhnlichen Suche beginnend im identifizierten übergeordneten Knoten der finger-suche. Diese Suche spart Kosten, wenn die Blätter v und w relativ nah aneinanderliegen. Die Anzahl der I/O-Operationen für die finger-suche liegt bei O(log B Q), wobei Q der Blätteranzahl zwischen v und w entspricht. Folglich kann eine finger-suche I/O-Operationen sparen anstatt das Blatt w (ebenfalls wie das Blatt v) von der Wurzel aus zu suchen. Abbildung 24: B-Baum mit Zeigern auf Nachbarknoten

41 Teilweise persistente B-Bäume Teilweise persistente B-Bäume werden manchmal auch Multiversionen B-Bäume genannt. Ihre Besonderheit liegt darin, dass der modifizierte B-Baum nach einer Updateoperation (Einfügen oder Löschen) als eine neue Version des B-Baumes gespeichert wird. Anfragen können an beliebige Versionen des B-Baumes gestellt werden. Die Sicherung verschiedener Versionen ist nützlich, wenn eine Historie der Datenstruktur für Anfragen benötigt wird. Mit Hilfe von Standardtechniken zur Realisierung von Persistenz im internen Speicher [DSST89, ST86] können auch persistente B-Bäume im externen Speicher effizient realisiert werden. Eine Folge von N Updateoperationen führt zu einer Datenstruktur, welche O ( N B ) externe Speicherblöcke benötigt. Anfragen in einer beliebigen Version des B- Baums erfordern O(log B N) I/O-Operationen. Range Anfragen (siehe Abschnitt 4.2.4) werden ebenfalls unterstützt. Details zu diesen Varianten können in [BGO + 96, ST86] gefunden werden String B-Bäume In diesem Kapitel wurde angenommen, dass die Schlüssel des B-Baums eine maximale Länge besitzen. Andernfalls könnte schließlich kein B definiert werden, welches die Maximalanzahl der Schlüssel in einem Speicherblock beschreibt. Falls die Schlüssellänge nicht begrenzt ist, können String B-Bäume [FG99] verwendet werden. Untersuchungen haben gezeigt, dass die Baumoperationen effizient in String B-Bäumen realisiert werden können. Weiterführende Informationen werden in [MSS03, Kapitel 7] gegeben.

42 38 5 Wörterbücher mit Hashing Werden Wörterbücher mit Hilfe von Hashing realisiert, wird die bestmögliche Performance für die elementaren Operationen (Einfügen, Löschen, Lookup) erreicht. In diesem Kapitel wird vorausgesetzt, dass eine Hashfunktion h(x) existiert, so dass jeder mögliche Hashwert mit der gleichen Wahrscheinlichkeit angenommen wird und er lediglich vom Eingabeparameter x (und keinem anderen Element) abhängt. Da der errechnete Hashwert beim Einfügen und Suchen eines Elementes verwendet wird, ist es erforderlich, dass die Hashfunktion für ein bestimmtes x stets den gleichen Hashwert liefert. Folglich werden in der Praxis pseudozufällige Funktionen verwendet. Abschnitt 5.1 stellt Realisierungen für Wörterücher mit Hashing vor, welche im Erwartungswert eine gute Performance aufweisen. Bei einigen Anwendungen genügt jedoch nicht die Effizienz im Erwartungswert und die schlechtest mögliche Performance wird betrachtet. Realisierung mit einer I/O-Operation im worst case werden in Abschnitt 5.2 beschrieben. Sie nutzen den internen Speicher so aus, dass elementare Operationen auch im worst case mit nur einer I/O-Operation durchgeführt werden können. Bei wachsender Datenanzahl im Wörterbuch ist es erforderlich, die Anzahl der Hashwerte zu erhöhen, die angenommen werden können. Abschnitt 5.3 stellt ein Möglichkeit für die dynamische Größenveränderung von Hashtabellen vor. 5.1 Effiziente Realisierungen im Erwartungswert Klassische Hashingverfahren wie Linear Probing oder Hashing mit verketteten Listen weisen im Erwartungsfall auch eine gute Performance im externen Speicher auf. Dieser Abschnitt liefert einen Überblick der zuvor genannten Verfahren. Für eine Übersicht weiterer Verfahren wird auf [Knu73, Abschnitt 6.4] verwiesen. Für beide Realisierungen wird im internen Speicher lediglich eine pseudozufällige Hashfunktion h gehalten, welche ein paar Bits an Speicherplatz benötigt Linear Probing Das Einfügeverfahren von Linear Probing im internen Speicher verfährt so, dass der Hashwert h(x) eine Position im Array bestimmt und das neue Element x in die erst freie

43 39 Position ab dem berechneten Hashwert gesetzt wird. Analog dazu bestimmt im externen Speicher der Hashwert den Speicherblock, welcher das Element aufnehmen soll. Hat dieser Block bereits seine Kapazitätsgrenze erreicht, wird versucht, das neue Element im darauf folgenden Block einzufügen usw. Die Suche verläuft ähnlich, so dass der Hashwert berechnet wird und sukzessive jeder Block nach x durchsucht wird bis ein Block gefunden ist, welcher nicht die volle Kapazität besitzt. Wurde ein nicht vollständig ausgefüllter Speicherblock gefunden, der das gesuchte Element nicht enthält, impliziert dies, dass das Element x nicht abgespeichert ist. Beim Löschen von Elementen, sind gegebenfalls einige Modifikationen erforderlich, da die Speicherblöcke nach einer Löschoperation den Zustand besitzen sollen, welchen sie ohne dem vorigen Einfügen des gelöschten Elementes besäßen. Da die Hashfunktion jeden Wert mit gleicher Wahrscheinlichkeit annimmt, sollten die Elemente auf den Speicherblöcken gleichverteilt sein. Das Ereignis, dass ein Block B i zuviele Elemente aufnehmen soll, scheint damit intuitiv als selten. Des Weiteren ist es noch unwahrscheinlicher, dass der darauf folgende Block ebenfalls die volle Kapazität besitzt. Um die Gefahr zu reduzieren, dass ein Speicherblock voll ist, werden mehr als N/B Blöcke verwendet. So hat jeder Block im Erwartungswert weniger als B Elemente und damit stets freie Kapazität für die Speicherung weiterer Elemente. Für diesen Aspekt wird der Auslastungsfaktor verwendet: Definition 5.1. α mit 0 < α < 1 ist der Auslastungsfaktor einer Hashtabelle, wenn im Erwartungswert jeder Speicherblock der Hashtablle zu α% gefüllt ist. α wird an Hand der Größe der Hashtabelle und den einzufügenden Elementen berechnet: Es gilt Anzahl der Speicherblöcke in der Hashtabelle = N αb. (11) Der Aufwand für Lookups steht in Abhängigkeit zum Auslastungsfaktor, da dieser schließlich die Wahrscheinlichkeit bestimmt, dass ein Block überläuft. Laufzeitanalyse 5.1. Bei einem Auslastungsfaktor 0 < α < 1 beträgt die erwartete Anzahl von I/O-

44 40 Operationen für ein Lookup gemäß [Knu73] (1 α) 2 2 Ω(B) (12) Falls α ɛ < 1, mit ɛ > 0 und B nicht zu klein ist, ist die erwartete Anzahl von I/O- Operationen nahezu 1. Die asymptotische Wahrscheinlichkeit, dass für ein Lookup k > 1 I/O-Operationen erforderlich sind, ist 2 Ω(B(k 1)). Falls die maximale Anzahl von Elementen des Wörterbuches nicht bekannt ist, wird die Hashtabelle vergrößert, wenn der Auslastungsfaktor zu hoch wird. Abschnitt 5.3 stellt eine Technik für die dynamische Vergrößerung von Hashtabellen vor Verkettete Listen Beim Hashing mit verketteten Listen wird im internen Speicher das Arrayfeld mit Hilfe der Hashfunktion bestimmt. Jedes Arrayfeld verweist auf eine verkettete Liste, welche alle Elemente aufnimmt, die dem Arrayfeld zugewiesen wurden (siehe Abbildung 25). Abbildung 25: Hashing mit verketteten Listen Dieses Konzept wird im externen Speicher übernommen, so dass die Hashwerte einen Speicherblock identifizieren, in dem eine verkettete Liste beginnt. Bei einem Lookup ist dann eine I/O-Operation erforderlich, wenn jede verkettete Liste nicht mehr als B Elemente besitzt. Die pseudozufällige Hashfunktion soll die Elemente gleichmäßig auf alle Speicherblöcke der Hashtabelle verteilen. Um die Wahrscheinlichkeit gering zu halten, dass eine verkettete Liste mehr als B Elemente besitzt, werden wie beim Linear Probing N/(αB) Speicherblöcke genutzt, um einen Auslastungsfaktor α zu erreichen.

45 41 Unter Verwendung des Auslastungsfaktors ist die verkettete Liste jedes Hashwertes im Erwartungsfall so klein, dass sie nur einen einzigen Speicherblock benötigt (und damit ein Lookup, Löschen oder Einfügen nur eine I/O-Operation kostet). Laufzeitanalyse 5.2. Für die Anzahl von I/O-Operationen ist die Wahrscheinlichkeit essentiell, dass bei k 1 zu einem beliebigen Speicherblock der Hashtabelle mit Größe N/(αB) mehr als kb Schlüssel gehasht werden. Mit Hilfe von Chernoff Grenzen [HR90, Eq. 6] kann gezeigt werden, dass diese Wahrscheinlichkeit höchstens e αb(k/α 1)2 3 (13) beträgt. Falls B groß ist und der Auslastungsfaktor α ɛ < 1, mit ɛ > 0, sind Überläufe sehr selten und im Erwartungsfall kostet eine Operation lediglich eine I/O-Operation. Im Vergleich der Laufzeit für Linear Probing (siehe Laufzeit 5.1) mit der für verkettete Listen, verkleinern sich die Wahrscheinlichkeiten mit k bei den verketteten Listen schneller als beim Linear Probing. Jedoch existiert für die Verwaltung der verketteten Listen ein Mehraufwand. 5.2 Realisierungen mit einer I/O im Worst Case Um eine Hashtabelle zu realisieren, die im worst case lediglich eine I/O-Operation benötigt, ist es erforderlich, den internen Speicher auszunutzen. Abschnitt gibt einen Überblick über zwei Ansätze, welche mit Hilfe des internen Speichers für die elementaren Methoden nie mehr als eine I/O-Operation benötigen. Ist eine höhrere Berechnungszeit vor dem Datenzugriff erlaubt, bieten Vorgänger-Wörterbücher (siehe Abschnitt 5.2.2) eine geeignete Alternative. Da meistens die Zeit für die Ausführung einer I/O-Operation und nicht die Anzahl von I/O-Operationen ausschlaggebend ist, können als dritte Alternative zwei I/O- Operationen parallel ausgeführt werden. Zwar führt dies zu doppelten Kosten, jedoch wird nicht mehr Zeit als für eine I/O-Operation benötigt. Abschnitt stellt eine Realisierung mit diesem Konzept vor.

46 Ausnutzung von internen Speicher Oftmals kann eine gute Effizienz für die Datenhaltung im externen Speicher erreicht werden, wenn der interne Speicher verwendet wird. Im Kapitel 3 wurde der interne Speicher als Buffer verwendet, um Elemente zwischenzuspeichern und nicht direkt in den externen Speicher auszulagern. Eine andere Alternative für die Nutzung des internen Speichers ist es, darin Verwaltungsdaten zu halten, um einen effizienten Zugriff auf die Daten im externen Speicher zu ermöglichen. Steht ausreichend interner Speicher zur Verfügung, kann ein Lookup im Wörterbuch stets mit nur einer einzigen I/O-Operation durchgeführt werden. Die Konzepte Überlaufgebiete sowie Perfektes Hashing ermöglichen diese bestmögliche Effizienz. Überlaufgebiete Die Idee der Überlaufgebiete ist es, die Schlüssel in einem Wörterbuch im internen Speicher zu halten, welche einem bereits vollen Speicherblock zugeordnet wurden. So werden Überläufe vermieden, indem die betreffenden Daten im internen Speicher abgelegt werden. Es ist offensichtlich, dass der interne verfügbare Speicher eine gewisse Größe besitzen muss, um alle möglichen Überläufe auffangen zu können. Kann der verfügbare interne Speicher 2 Ω(B) N Schlüssel-Wert Paare aufnehmen, so wird bei einem konstanten Auslastungsfaktor α < 1 nur eine I/O-Operation für einen Lookup benötigt. Denn für eine Konstante c(α) = Ω(1 α) ist die Wahrscheinlichkeit für die Abspeicherung von mehr als 2 Ω(B) N Schlüssel-Wert Paaren im internen Speicher so gering, dass der Aufwand akzeptabel ist, beim Überlauf des internen Speichers alle Daten mit einer neuen Hashfunktion neu zu verteilen. Die Überlaufgebiete können auch im externen Speicher gehalten werden. Um Lookups mit nur einer I/O-Operation durchführen zu können, bedarf es Datenstrukturen im internen Speicher, die zum einen die übergelaufenen Blöcke identifizieren und zum anderen den Datenzugriff auf Elemente von übergelaufenen Blöcken mit nur einer I/O-Operation ermöglichen. Die Identifizierung von übergelaufenen Blöcken kann mit Hilfe eines Wörterbuches im internen Speicher erfolgen, welches die betreffenden Blocknummern beinhaltet. Da die Wahrscheinlichkeit, dass ein Block überläuft, bei O ( 2 c(α)b) liegt, ist es im Erwartungswert erforderlich, O ( 2 c(α)b) Indizes von übergelaufenen Speicherblöcken zu halten. Der benötigte interne Speicherplatz hängt von der Länge der Indizes ab, welche wiederum auf der Anzahl der Daten N des Wörterbuches basiert. Somit werden

47 43 im Erwartungswert O ( 2 c(α)b N log N ) Bits für die Sicherung der Indizes im internen Speicher benötigt. Die zweite Anforderung, den Datenzugriff auf übergelaufene Blöcke mit nur einer I/O-Operation zu realisieren, kann durch ein zusätzliches Wörterbuch ermöglicht werden. Die Daten, welche zu einem übergelaufenen Block zugeordnet wurden, werden in einem anderen Wörterbuch gehalten, welches ein Lookup mit nur einer I/O- Operation durchführt. Die Daten übergelaufener Blöcke im ursprünglichen Wörterbuch werden in diesem Fall nicht mehr betrachtet. Die Anzahl der im zusätzlichen Wörterbuch abzuspeichernden Elemente liegt mit einer hohen Wahrscheinlichkeit bei O ( 2 c(α)b N ). Perfektes Hashing Beim perfekten Hashing existieren keine Überläufe. Mairson [Mai83] entwickelte eine B perfekte Hashfunktion p : K {1,..., N/B }, welche höchstens B Eingabeparameter einem Hashwert zuordnet. Damit erhält jeder Speicherblock höchstens B Elemente und die Kapazität keines Blockes ist zu gering. Liegt die Hashfunktion im internen Speicher, so bedarf es für einen Lookup lediglich einer I/O-Operation, da jedes Element in dem Speicherblock zu finden ist, welches die Hashfunktion berechnet. Es wurde ( gezeigt, ) dass B-perfekte Hashfunktionen existieren und in vereinfachter Schreibweise O N log(b) Bits internen Speicherplatz benötigt. Dies ist optimal, wenn N/B Speicherblöcke und eine beliebige Schlüsselmenge verwendet werden. Dieses Konzept hat jedoch einige signifikante Nachteile, da sowohl der benötigte Platz als auch die Zeit für die Berechnung der Hashfunktion extrem hoch sind. Zudem scheint es schwierig, eine dynamische Version bezüglich der Datenanzahl N zu entwickeln. Aus diesen Gründen ist Mairsons Verfahren praktisch kaum einsetzbar. Fagin et al. [FNPS79] haben ein B-perfektes Hashing entwickelt, welches praktisch einsetzbar ist und nah an die untere Grenze von Mairson gelangt. Dieses Verfahren wird erweiterbares Hashing genannt. B Vorgänger-Wörterbuch Vorgänger-Wörterbücher benötigen weniger internen und externen Speicherplatz als B-perfekte Hashingmethoden. Jedoch ist der Berechnungsaufwand des zugehörigen Speicherblocks nicht konstant sondern beträgt O(log log N) pro Wörterbuchmethode. Das Vorgänger-Wörterbuch unterstützt Vorgänger-Anfragen in einer Schlüsselmenge P {0, 1} r, welche für jeden Schlüssel x {0, 1} r den größten Schlüssel y P mit

48 44 y x zuzüglich weiteren Informationen über den Speicherort zurückgibt. Für die Realisierung des Wörterbuches im externen Speicher werden die Schlüssel der abzuspeichernden Daten in einer verketteten Liste abgelegt. Die Sortierung in der Liste erfolgt auf Basis der Hashwerte. Für jeden Speicherblock B i der verketteten Liste wird im internen Vorgänger-Wörterbuch das Element mit dem kleinsten Schlüssel in B i abgespeichert. Als zugehörige Informationen des Schlüssels wird im Vorgänger-Wörterbuch ein Zeiger auf den Speicherblock gesichert. Die Schlüsselmenge P ist somit eine Teilmenge der Schlüsselmenge des externen Wörterbuches. Für ein Lookup des Elementes x wird eine Vorgänger-Anfrage durchgeführt, um auf Basis von h(x) den kleinsten Schlüssel des gleichen Speicherblockes zu finden. Der zugehörige Zeiger des Vorgängers von x ermöglicht einen Zugriff auf den gesuchten Speicherblock mit einer I/O-Operation. Einfügen und Löschen erfordern eine Modifikation der Daten in einem Speicherblock der verketteten Liste. Falls das eingefügte oder gelöschte Element der kleinste Schlüssel in einem Block ist, ist zudem eine konstanten Anzahl von Updates des Vorgänger-Wörterbuches erforderlich. Laufzeitanalyse 5.3. Das interne Vorgänger-Wörterbuch benötigt höchstens N (1 ɛ)b Schlüssel mit ɛ > 0. Wenn die Hashwerte in einem Intervall liegen, so dass 3 log N r = O(log N), benötigen die Hashwerte O(log N) Bits. Unter Verwendung von van Emde Boas Bäumen können Vorgänger-Anfragen in O(log log N) Zeit ausgeführt werden [Wil83]. Modifikationen benötigen im Erwartungswert die gleiche Zeit. Für ein Lookup wird nur eine I/O-Operation benötigt, jedoch geht dem Datenzugriff eine Berechnungszeit von O(log log N) voraus. Diese Zeit ist in der Praxis meistens vernachlässigbar. Die Nutzung des externen Speichers ist gewöhnlich sehr nah an der Optimalität und O(N/B) interner Speicherplatz werden verbraucht Parallele I/O-Operationen Da in den meisten Fällen die Zeitspanne und nicht der Aufwand für die Durchführung von I/O-Operationen betrachtet wird, ist ein anderer Ansatz zwei I/O-Operationen parallel auszuführen. Schließlich benötigt eine parallele Ausführung zweier I/O-Operationen

49 45 genauso viel Zeit wie eine einzige I/O-Operation. Ein paralleler Zugriff auf externen Speicher ist möglich, wenn er in mindestens zwei Teile aufgeteilt wird, wie zum Beispiel durch unabhängige Festplatten oder Arbeitsspeicherbänke. Azar et al [ABKU00] entwickelten das so genannte two-way chaining, welches auf parallelen I/O-Operationen basiert. Dabei werden zwei pseudozufällige Hashfunktionen h 1 und h 2 für zwei Hashtabellen verwendet. Die Hashtabellen werden in den getrennten Teilen des externen Speichers gehalten. Für die Suche nach Schlüssel x werden parallel die Blöcke h 1 (x) und h 2 (x) untersucht. Bei einem Ladefaktor α << 1 und einer angemessenen Blockgröße, ist die Wahrscheinlichkeit groß, dass sich das Element x in einen dieser Blöcke befindet und somit eine der parallelen I/O-Operationen erfolgreich ist. Um eine ausgewogene Verteilung der Daten in beide Hashtabellen zu erreichen, wird ein neues Element y in den Block h i (y) mit i {1, 2} eingefügt, welches weniger Elemente besitzt. In [BCSV00] haben Berenbrink et al. gezeigt, dass die Wahrscheinlichkeit für einen Überlauf bei einer Einfügeoperation N/2 2Ω((1 α)b) beträgt. Da im Nenner 2 2Ω((1 α)b) steht, sinkt die Fehlerwahrscheinlichkeit doppelt exponentiell mit der durchschnittlichen Anzahl von freien Plätzen in jedem Block. Der konstante Faktor (1 α)b ist größer als Eins. Experimentell wurde gezeigt, dass sogar für wenig freie Plätze in jedem Block, die Wahrscheinlichkeit für einen Üerlauf sehr klein ist [MB]. 5.3 Anpassung der Hashtabellengröße Ein wichtiges Konzept der vorigen Abschnitten ist die Nutzung eines Auslastungsfaktors α, um eine geringe Wahrscheinlichkeit von Überläufen zu erhalten. So werden mehr Speicherblöcke für die Hashtabelle allokiert als minimal für die Sicherung der Eingabedaten benötigt werden. Da mit Hilfe des Auslastungsfaktors α < 1 die Wahrscheinlichkeit für Überläufe sehr gering ist, können Hashtabellenoperation mit einer I/O-Operation durchgeführt werden. Ist die Anzahl von Eingabedaten konstant und bekannt, kann der Ladefaktor bei der Erzeugung der Hashtabelle berechnet werden. Wenn die Anzahl der Eingabedaten signifikant schwankt, ist es aus Speichergründen nicht effizient, die maximale Anzahl von Eingabedaten für die Hashtabellengröße zu Grunde zu legen. Eine dynamische Anpassung der Hashtabellengröße ist eine in Bezug auf den Speicherplatz effizientere Vorgehensweise. Die dynamische Vergrößerung oder Verkleinerung ist ebenso notwendig, wenn die maximale Anzahl der Eingabedaten nicht vor der Initialisierung

50 46 der Hashtabelle definiert werden kann. Dieser Abschnitt stellt eine Möglichkeit für die Anpassung der Hashtabellengröße dar. Eine Vergrößerung der Hashtabelle ist notwendig, wenn der Auslastungsfaktors überschritten wird und damit die Wahrscheinlichkeit für Überläufe anwächst. Steigt diese Wahrscheinlichkeit, ist es unwahrscheinlicher nur eine I/O-Operation pro Hashtabellenmethode zu benötigen. Wird der Auslastungsfaktors deutlich unterschritten, beeinflusst dieses nicht die Zugriffsperformance. Jedoch wird mehr Speicherplatz verbraucht, als benötigt wird. Folglich soll dieser Fall ebenfalls durch eine Anpassung der Hashtabellengröße behandelt werden. Die trivialste Lösung für die Änderung der Hashtabellengröße ist es, die Größe anzupassen, eine andere Hashfunktion zu wählen und alle Elemente mit einem rehash neu einzufügen. Diese Vorgehensweise ist jedoch nicht effizient, da beim rehash für viele Elemente der Hashtabelle eine I/O-Operation für das Einfügen in einem anderen Speicherblock benötigt wird. ( In [AV88] wurde dieses Permutationsverfahren mit dem Ergebnis untersucht, dass Θ log ( N N ) ) B M I/O-Operationen erforderlich sind. Wenn N nicht extrem B B groß ist, entspricht diese Performance O(N) I/O-Operationen. Um eine effiziente Lösung beim der Größenanpassung für beliebig große Datenmengen zu erreichen, wäre ein konstanter Aufwand wünschenswert. Folgich liegt die Herausforderung bei der Anpassung der Hashtabelle darin, eine Reorganization aller bereits enthaltenen Daten zu vermeiden und die Anpassung effizient durchzuführen. Das lineare Hashing von Litwin [Lit80] realisiert eine schrittweise Vergrößerung oder Verkleinerung von Hashtabellen. Dei Berechnung des Hashwertes erfolgt mit einer übergeordneten Mutterfunktion die eine Vielzahl von Hashwerten liefert. Dieser Hashwert wird entweder übernommen, wenn er nicht größer als die aktuelle Tabellengröße ist. Andernfalls wird der Hashwert k verkleinert mit Hilfe von k 2 b 1, wobei b = log r gilt. Für eine effiziente Realisierung der Vergrößerung wird ausgenutzt, dass der Rückgabewert bei größeren Werten als r der Form k 2 b 1 erfolgte. Da der neue Block k = r + 1 entspricht, müssen lediglich Elemente im Block (r + 1) 2 b 1 neu zugeordnet werden. Es ist wichtig zu beachten, dass nicht alle Elemente des Blockes (r + 1) 2 b 1 in den neuen Block r + 1 verlagert werden. Es werden nur die Elemente in Block r + 1 gesetzt, die dort eingefügt worden wären, falls zuvor die Tabelle r + 1 Blöcke gehabt hätte. Das Verkleinern der Hashtabelle verläuft ähnlich. Lediglich die Elemente im letzten Block r müssen auf die anderen r 1 Blöcke verteilt werden. Dazu wird von allen Elementen der Wert der Stamm-Hashfunktion berechnet, mit r 1 verglichen und die Daten dem Block zugeordnet, der dem neuen Rückgabewert entspricht.

51 47 Dieses Verfahren für die Anpassung der Hashtabellengröße ist problematisch, wenn r keine Potenz von 2 ist. Da die Tabellengröße immer um Eins erhöht oder reduziert wird, ist die aktuelle Tabellengröße oftmals keine 2er Potenz. Liegt keine 2er Potenz zu Grunde, dann werden die Elemente nicht gleichmäßig auf die Blöcke der Hashtabelle verteilt. Damit ist die Wahrscheinlichkeit für Überläufe deutlich höher, was vermieden werden sollte. Um stets eine Gleichverteilung auf die Blöcke zu erhalten, bedarf es einer uniformen Hashfunktion bei jeder Tabellengröße. Dafür erfolgt die Anpassung der Hashtabellengröße nicht um Eins, sondern um einen Faktor 1 + ɛ mit ɛ > 0 [sp02].

52 48 6 Zusammenfassung Da sich die verschiedenen Speichertypen in Schnelligkeit und Kosten deutlich unterscheiden, werden für das Ablegen von Daten oftmals verschiedene Speichertypen verwendet (zum Beispiel Arbeitsspeicher und Hauptspeicher). Um beliebige Kombinationen verschiedener Speichertypen einfach zu modellieren, hat sich das externe Speichermodell als geeignet erwiesen, da seine geringe Komplexität Vergleiche mehrere Entwürfe ermöglicht. Die Datenbereitstellung des externen Speichermedium wird als Flaschenhals der Performance angesehen, da die Bandbreite es erlaubt, mehrere Datenelemente gleichzeitig zu übermitteln. In dieser Seminararbeit wurde ein Datentransfer von B Datenelementen mit einer I/O-Operation definiert. Das Ziel für die Datenauslagerung im externen Speicher ist es somit, möglichst wenige I/O-Operation zu benötigen. Aus dieser Zielsetzung folgt, dass bei der Nutzung von internen Datenstrukturen für die externe Speicherung in vielen Fällen eine I/O-Operation pro Datenelement erforderlich ist. Jedoch können pro I/O-Operation B Elemente ausgetauscht werden, so dass die internen Implementierungen von Stack, Queue und Wörterbuch durch effizientere Lösungen im externen Speicher ersetzt werden können. Diese Seminararbeit stellt Realisierungen dieser Datenstrukturen für die Nutzung von externem Speicher vor. Der externe Stack und die externe Queue nutzen einen Teilbereich des internen Speichers als Buffer, um eine amortisierte Laufzeit von 1 I/O-Operationen zu benötigen. Dieser Wert ist auf Grund der Bandbreite von B B die bestmögliche Performance. Für die Implementierung eines Wörterbuches können B-Bäume oder Hashing verwendet werden. Die Realisierung mit Hashing nutzt ebenfalls einen Teil des internen Speichers und ist sehr effizient, da für jedes Lookup lediglich eine I/O-Operation aufgebracht werden muss. Dies wird dadurch erreicht, dass uniforme Hashfunktionen verwendet werden und zudem mehr Blöcke als die minimale erforderliche Anzahl genutzt werden. Ein Auslastungsfaktor α wird definiert, um die Wahrscheinlichkeit gering zu halten, dass Überläufe auftreten. Sind insgesamt deutlich mehr oder weniger Elemente in der Hashtabelle enthalten als durch den Auslastungsfaktor definiert, kann die Hashtabellengröße dynamisch angepasst werden. Bei der Realisierung eines Wörterbuches mit einem B-Baum entspricht die Anzahl der I/O-Operationen der Baumtiefe. Es ist wichtig, dass bei der Nutzung von B-Bäumen eine Gesamtordnung der Schlüsselwerte gefordert wird, da das Einfügen der Elemente dieser Ordnung unterliegt. In der Seminararbeit wurden der Fokus auf gewichtete B-

53 49 Bäume gelegt, die bei einer Speicherung von N Datenelemente und einer Blockgröße B eine maximale Baumhöhe von 1 + log B/8 N besitzen. Für einzelne Lookup Operationen und große N ist die Realisierung mit Hashing effizienter, jedoch bieten B-Bäume die Option andere Anfragetypen, wie das Range Reporting (siehe Abschnitt 4.2.4), schneller auszuführen. Wird die Wurzel im internen Speicher gehalten, ist die Anzahl von I/O- Operationen auch bei vielen Datenelementen gering, da ein Knoten einen Ausgangsgrad zwischen B und B besitzt. Folglich sollte die genutzte Implementierung des Wörterbuches abhängig von den gefordertem Funktionsumfang gewählt 32 2 werden.

54 50 Literatur [ABKU00] Azar, Yossi, Andrei Z. Broder, Anna R. Karlin und Eli Upfal: Balanced Allocations. SIAM Journal on Computing, 29(1): , [AM99] [Arg95] Arge, Lars und Peter Bro Miltersen: On showing lower bounds for external-memory computational geometry problems. Seiten , Arge, Lars: The Buffer Tree: A New Technique for Optimal I/O- Algorithms (Extended Abstract). In: WADS 95: Proceedings of the 4th International Workshop on Algorithms and Data Structures, Seiten , London, UK, Springer-Verlag. [Arg04] Arge, Lars: External Geometric Data Structures. In: COCOON, Seite 1, [AV88] [AV96] [BCSV00] Aggarwal, Alok und Jeffrey S. Vitter: The input/output complexity of sorting and related problems. Commun. ACM, 31(9): , Arge, Lars und Jeffrey Scott Vitter: Optimal Dynamic Interval Management in External Memory (extended abstract). In: IEEE Symposium on Foundations of Computer Science, Seiten , Berenbrink, Petra, Artur Czumaj, Angelika Steger und Berthold Vöcking: Balanced allocations: the heavily loaded case. Seiten , [BGO + 96] Becker, Bruno, Stephan Gschwind, Thomas Ohler, Bernhard Seeger und Peter Widmayer: An asymptotically optimal multiversion B-tree. The VLDB Journal, 5(4): , [BM72] Bayer, Rudolf und Edward M. McCreight: Organization and Maintenance of Large Ordered Indices. Acta Inf., 1: , [Com79] Comer, Douglas: The Ubiquitous B-Tree. ACM Comput. Surv., 11(2): , [DKM + 94] Dietzfelbinger, Martin, Anna Karlin, Kurt Mehlhorn, Friedhelm Meyer auf der Heide, Hans Rohnert und Robert Endre Tarjan: Dynamic Perfect Hashing: Upper and Lower Bounds. SIAM J. Comput., 23(4): , [DSST89] Driscoll, James R., Neil Sarnak, Daniel D. Sleator und Robert E. Tarjan: Making data structures persistent. J. Comput. Syst. Sci., 38(1):86 124, 1989.

55 51 [FG99] [FHL + 01] [FNPS79] [HM82] [HR90] [Knu73] [Lit80] [Mai83] [MB] [MSS03] [RL90] Ferragina, Paolo und Roberto Grossi: The string B-tree: a new data structure for string search in external memory and its applications. Journal of the ACM, 46(2): , Ferdinand, Christian, Reinhold Heckmann, Marc Langenbach, Florian Martin, Michael Schmidt, Henrik Theiling, Stephan Thesing und Reinhard Wilhelm: Reliable and Precise WCET Determination for a Real-Life Processor. In: EMSOFT 01: Proceedings of the First International Workshop on Embedded Software, Seiten , London, UK, Springer-Verlag. Fagin, Ronald, Jürg Nievergelt, Nicholas Pippenger und H. Raymond Strong: Extendible Hashing - A Fast Access Method for Dynamic Files. ACM Trans. Database Syst., 4(3): , Huddleston, Scott und Kurt Mehlhorn: A New Data Structure for Representing Sorted Lists. Acta Inf., 17: , Hagerup, Torben und C. Rüb: A guided tour of Chernoff bounds. Inf. Process. Lett., 33(6): , Knuth, Donald E.: The Art of Computer Programming, Volume I: Fundamental Algorithms, 2nd Edition. Addison-Wesley, Litwin, W.: Linear hashing: A new tool for files and tables addressing. In: Proceedings of International Conference On Very Large Data Bases (VLDB 80), Seiten IEEE Computer Society Press, Mairson, Harry G.: The Program Complexity of Searching a Table. In: FOCS, Seiten 40 47, Mitzenmacher, Michael und Andrei Broder: Using Multiple Hash Functions to Improve IP Lookups. Seiten Meyer, Ulrich, Peter Sanders und Jop F. Sibeyn (Herausgeber): Algorithms for Memory Hierarchies, Advanced Lectures [Dagstuhl Research Seminar, March 10-14, 2002], Band 2625 der Reihe Lecture Notes in Computer Science. Springer, Rivest, Ronald L. und Charles E. Leiserson: Introduction to Algorithms. McGraw-Hill, Inc., New York, NY, USA, [sp02] Östlin, A. und R. Pagh: Rehashing rehashed. Technischer Bericht, [ST86] Sarnak, Neil und Robert E. Tarjan: Planar point location using persistent search trees. Commun. ACM, 29(7): , 1986.

56 52 [Vit01] [vn45] [VS94] [Wil83] Vitter, Jeffrey Scott: External memory algorithms and data structures: dealing with massive data. ACM Computing Surveys, 33(2): , Neumann, John von: First Draft of a Report on the EDVAC. Technischer Bericht, Vitter, Jeffrey Scott und E. A. M. Shriver: Algorithms for parallel memory, I: Two-level memories. Algorithmica, 12(2/3): , Willard, Dan E.: Log-Logarithmic Worst-Case Range Queries are Possible in Space Theta(N). Inf. Process. Lett., 17(2):81 84, 1983.

57 53 A Survey of Techniques for Designing I/O-Efficient Algorithms 1 Einleitung Oliver Buschjost 4. Februar 2007 Bei der Arbeit mit großen Datenmengen treten Geschwindigkeitsprobleme auf, die durch zu häufige I/O-Zugriffe verursacht werden. Das Problem entsteht, da nicht alle relevanten Daten im Hauptspeicher vorgehalten werden können und immer wieder neu von den langsamen Massenspeichern angefordert werden müssen. Um dieses Problem zu verringern kann an mehreren Stellen angesetzt werden: einerseits bei den Algorithmen selber, indem der Ablauf mehr auf die Struktur der Daten auf dem Massenspeicher eingeht. Andererseits lässt sich durch eine intelligente Speicherung der Daten dem zu häufigen nachladen entgegenwirken. Im Folgenden sollen zwei Methoden, das List-Ranking und das Graph-Blocking, exemplarisch herausgegriffen werden, um I/O Effiziente Algorithmen zu entwickeln. Für die Grundlagen wie das effiziente Sortieren von externen Daten mit einem modifizierten Merge-Sort sowie Techniken wie die Simulation von PRAM-Algorithmen für externe Daten und den Ansatz des Time-Forward Processing sei auf das Paper [1] verwiesen. Es wird mit dieser Notation gearbeitet: N Größe der Eingabedaten B Größe eine Blocks auf dem Massenspeicher M Größe des zur Verfügung stehenden internen Speichers 2 List Ranking Häufig wird die Reihenfolge von Daten in einer Liste benötigt. Dies entspricht dem Problem des List Ranking, bei welchem der Abstand von allen Elementen zum Ende der Liste berechnet wird. Intuitiv denkt man bei Listen meist daran, dass die Elemente geordnet in Reihenfolge der Liste vorliegen und mit einfachem Durchlaufen nummeriert werden können (siehe Abbildung 1). Dies ist leider in der Praxis nicht der Fall Abbildung 1: optimal angeordnete verkettete Liste Für kleine Datenmengen, die im internen Speicher vorgehalten werden können, kann der naive Ansatz des Durchlaufens das Problem (auf einer Ein-Prozessor Maschine) effizient lösen. In diesem Fall wird die Arbeit O(n) (Zeitdauer und Anzahl benötigter Operationen) aufgewendet. Dies Verfahren skaliert aber nicht, lässt sich

58 54 also nicht auf mehrere Prozessoren verteilen, um die Verarbeitungszeit zu reduzieren. Es gibt daher mehrere Algorithmen, die das List-Ranking Problem auf andere Weise parallelisiert lösen und dafür Zeit O(log(n)) (bei allerdings mit O(n log(n)) insgesamt mehr auszuführenden Operationen) benötigen. Das Problem den naiven Algorithmus auf grosse extern gespeicherte Datenmengen anzuwenden ist ein anderes, die Lösung aber ähnlich zu den parallelisierten Verfahren. Das Problem bei den externen Daten ist, dass im besten Fall theoretisch nur O(N/B) I/O Operationen benötigt werden (wenn die Daten korrekt als Sequenz vorliegen), was aber meist nicht der Fall ist. Die Daten können so platziert sein, dass für jeden nächsten Knoten ein neuer Block gelesen werden muss, also ganze O(N) I/O Operationen stattfinden müssen. Ein Beispiel für eine solche Anordnung ist in Abbildung 2 dargestellt Abbildung 2: in Blöcken schlecht angeordnete verkettete Liste Das hier vorgestellte Verfahren löst nicht nur das List-Ranking Problem, sondern ein Allgemeineres Problem, welches häufig als List-Ranking bezeichnet wird, da bei der Lösung die Ergebnisse für das List-Ranking gleich mitgeliefert werden. Es werden dabei zur Lösung die Techniken des Pointer-Jumping und das zeitweise Entfernen von unabhängigen Mengen angewendet, um eine Laufzeit von O(sort(N)) I/O-Zugriffen zu erreichen. Die Verallgemeinerung des Problems führt eine Funktion λ ein, welche definiert ist als: x 1,, x n X, und die Knoten beschriftet. Zusätzlich wird eine Multiplikation über X definiert als: : X X X. Zusätzlich wird eine Permutation der Knoten definiert als σ : [1, N] [1, N] so, dass der Kopf der Liste als x σ(1) bezeichnet wird und für alle anderen Knoten gilt, dass Nachfolger(x σi ) = x σi+1 ( 1 i < N). Auf Basis dieser Funktionen wird jedem Knoten eine Beschriftung mit der Funktion φ(x i ) so zugewiesen, dass die folgenden Bedingungen erfüllt sind: φ(x σ(1) ) = λ(x σ(1) ) φ(x σ(i) ) = φ(x σ(i 1) ) λ(x σ(i) ) 1 < i N Die Funktion φ(x i ) ist also so definiert, dass der erste Knoten der Liste sein Label behält und nachfolgende Knoten das Label ihres Vorgängers multipliziert mit Ihrem eigenen bekommen. Dieses verallgemeinerte Problem kann durch das Wechseln der initialen Knotenlabel und der Berechnungsfunktion zum Lösen von einer Vielzahl von Problemen verwendet werden: die Nutzung zur Berechnung von Präfix- und Suffix-Summen oder von beliebigen Funktionen, die über die komplette Liste angewendet werden, ist ebenso möglich, wie die Nutzung zum Durchlaufen von Bäumen, um eine Pre- Order Nummerierung der Knoten zu bestimmen.

59 55 Der Ablauf des dafür nötigen Algorithmus lässt sich in drei Teile gliedern: 1. Aufteilen in kleinere Probleme durch Herauslösen von Knoten 2. Lösen der Teilprobleme 3. Zusammenfügen der Teilprobleme zur Gesamtllösung Es wird davon ausgegangen, dass die Multiplikationsfunktion assoziativ ist - die Reihenfolge in der die Knoten verarbeitet werden also für das Endergebnis keine Rolle spielt. Der Beweis hierfür wird am Ende nachgeliefert. Für die Aufteilung des Problems in kleine Teilprobleme ist diese Eigenschaft wichtig. Hier wird die Verfahren der Graph-Verkleinerung 1 in Kombination Pointer-Jumping verwendet. Das Pointer-Jumping ist wichtig, da in früheren Ansätzen bei der Parallelisierung Probleme beim gleichmäßigen Verteilen der Daten auf die Prozessoren auftraten, weil die bei der Graph-Verkleinerung entfernten Knoten teilweise dennoch von den Prozessoren angefasst werden mussten, was zu Laufzeitverlusten führte. In Abbildung 3 ist das Pointer-Jumping dargestellt. Es wird dabei ein alternativer Pfad durch die Knoten aufgebaut, der die gerade nicht benötigten Knoten direkt überspringt. Weitere Informationen zu den durch das Pointer-Jumping eingeführten Vorteilen lassen sich [4] entnehmen. Abbildung 3: Überbrücken von größeren Strecken beim Pointer-Jumping Die aus dem Gesamtsystem zu entfernenden Knoten müssen einer vom Rest unabhängigen Teilmenge angehören. Eine unabhängige Menge in einer Liste bedeutet, dass der Nachfolger jedes Knotens sich in der jeweils anderen Menge befinden muss (Abbildung 4). Abbildung 4: Zwei unabhängige Mengen in einer Liste Ein Ziel bei der Bestimmung der unabhängigen Menge I ist eine bestimmte Mindestgröße, nämlich I = Ω(N). Zur Bestimmung dieser unabhängigen Menge können verschiedene Verfahren mit unterschiedlichen Laufzeiten verwendet werden. 1 engl.: graph contraction

60 56 Hierbei wurden oft randomisierte Verfahren oder eine 3-Färbung eingesetzt (siehe [2, 5.2ff] ). Es gibt aber auch die Möglichkeit das Problem in O(sort(N)) I/O- Operationen zu lösen durch Verwendung des in [1, 3.5.1] beschriebenen Algorithmus zur Berechnung der größten unabhängigen Menge, da eine solche in einer Liste immer mindestens N/3 beträgt 2. Nun werden die so bestimmten Knoten der Teilmenge I mittels Pointer-Jumping aus der Liste entfernt. Angenommen die Knoten X, Y und Z mit Y I sind in dieser Reihenfolge in der Liste verknüpft, so ist der Knoten Y der zu entfernende. Es wird jetzt der Next Zeiger des Knoten X mit dem Inhalt des Next-Zeigers von Knoten Y überschrieben. Damit zeigt X fortan auf Z. Gleichzeitig wird das Label des Knoten Z aktualisiert: auf den eigenen Wert multipliziert mit dem Wert des entfernten Knotens Y. Die Aktualisierung der Zeiger erfolgt nicht auf den Originaldaten, sondern diese Pointer werden als separate Liste verwaltet, da sonst die Informationen über die Originalliste verloren gingen. Auf diese Weise wird die Länge der Liste verkleinert. Die in der neuen, verkleinerten Liste L I enthaltenen Knoten haben sich aufgrund der Assoziativität der Funktion im Endergebnis über die Liste nicht im Wert verändert, obwohl Knoten aus der Liste entfernt wurden. Dies liegt daran, dass die Label der entfernten Knoten in die Label Ihrer Nachfolger in die verkleinerte Liste eingearbeitet wurden. Die neue Liste L I kann also berechnet werden, indem das Verfahren rekursiv auf sie angewendet wird. Sobald nach mehreren Durchläufen die Werte für alle Knoten aus L I bekannt sind kann damit begonnen werden, alle entfernten Knoten wieder in die Liste einzufügen, bis L rekonstruiert ist. Dies wird erreicht, indem die Label aller Knoten aus der Menge I jeweils mit den Labeln Ihrer Vorgänger aus der ursprünglichen Liste berechnet werden: Wieder angenommen die Knoten X, Y und Z mit Y I sind in dieser Reihenfolge in der Ursprungsliste verknüpft. Nun enthält die aktuelle Liste die Knoten X und Z zwischen die der Knoten Y wieder eingefügt werden soll. Es wird jetzt der Next Zeiger des Knoten X mit dem Knoten Y überschrieben. Damit zeigt X fortan wieder auf Y. Gleichzeitig wird das Label des Knoten Y aktualisiert: auf den eigenen Wert multipliziert mit dem Wert des Vorgänger-Knotens X. Nachdem alle Knoten wieder eingefügt sind ist das Verfahren beendet und die Ergebnisse können aus den Labeln der Knoten ausgelesen werden. Sobald der unabhängige Menge bekannt ist, ist die verkleinerte Liste schnell erstellt: O(sort(N)) I/O Operationen werden dafür benötigt. In diesen Operationen werden die Knoten von I nach ihren Vorgängerknoten und die in L I nach ihren eigenen IDs sortiert und danach einmal durchlaufen, um die Beschriftungen von allen Nachfolgern der in I enthaltenen Knoten zu aktualisieren. Auf die selbe Weise erfolgt die Aktualisierung der Pointer, um diese Knoten fortan zu überspringen: hier wird L I nach den Nachfolgern und I nach den eigenen IDs sortiert. Und danach in einem einzelnen Scan über die beiden Listen jeweils die Next-Pointer der Elemente in L I mit denen der Elemente aus I ersetzt. Auf die Laufzeit hat die Rekursion natürlich einen sehr starken Einfluss. Ohne die Rekursion benötigte der Algorithmus für einen Schritt die eben gezeigten O(sort(N)) I/Os. Die die Rekursion beschreibende Rekurrenz-Gleichung lautet I(N) = I(cN) + O(sort(N)) (mit einem festen c unter der Bedingung 0 < c < 1). Diese lässt sich mit Hilfe des 3. Falls des Master-Theorems zu dem Ergebnis O(sort(N)) auflösen. Damit benötigt der Algorithmus insgesamt O(sort(N)) I/O-Operationen, was eine starke Verbesserung gegenüber den O(N) benötigten I/O-Operationen des internen Algorithmus auf externen Daten darstellt. 2 Gegeben: Liste mit drei Knoten. Zwei Knoten befinden sich in der ersten und ein Knoten in der zweiten Menge. Bei größeren oder kleineren Listen nähert sich die unabhängige Menge eher N/2 an.

61 57 Als letztes soll der oben angesprochene Beweis über die angenommene Assoziativität nachgereicht werden. Die angesprochene Assoziativität über X kann angenommen werden, da sie bei nicht Vorhandensein ohne eine Verschlechterung der Laufzeit simuliert werden kann. Um dieses zu Beweisen dient eine beliebige Liste L als Input. Von dieser Liste wird der Abstand eines jeden Knoten zum Kopf der Liste per List-Ranking bestimmt und anschließend werden die ungeordneten Knoten in die soeben bestimmte Reihenfolge aufsteigend sortiert. Auf dieser vorhandenen Liste kann nun mit dem internen-speicher Algorithmus mit einem einzelnen Scan über die Liste die gewünschte Funktion bestimmt werden. Der Scan hat dabei eine Laufzeit von O(scan(N)) I/O Operationen. Die Herstellung der sortierten Folge hatte einen Zeitaufwand von O(sort(N)). Damit liegt auch die Laufzeit des Gesamtverfahrens bei O(sort(N)) und es gibt keine Veränderung der Laufzeit durch die Annahme der Assoziativität. 3 Graph Blocking Unter Graph-Blocking versteht man das effiziente Verteilen der Graphinformationen in Blöcke, so dass möglichst wenig I/O Zugriffe für das traversieren des Graphen nötig sind. Es wird hierbei von so grossen Graphen ausgegangen, dass ein vollständiges Laden in den Speicher nicht möglich ist. Um die Effizienz von Graph-Blocking zu untersuchen wird mit der Anzahl der auftretenden Page-Faults (Seitenzugriffs- Fehlern) gearbeitet. Ein solcher tritt auf, sobald auf einen Knoten des Graphen zugegriffen wird, der sich nicht im Speicher befindet. Es wird beim Graph Blocking davon ausgegangen, dass sich der Graph nicht verändert. Dieses ermöglicht es, auf effiziente Updates des Graphen keine Rücksicht zu nehmen. Durch diese Einschränkung wird vieles erleichtert, da der Graph nun redundant auf dem Speicher abgelegt werden kann. Es wird festgelegt, dass es beim traversieren genügt einen der gespeicherten Knoten zu besuchen, dass also keine bestimmte fest definierte Kopie benötigt wird. Dies ist möglich, da jede Kopie Informationen über Vorgänger und Nachfolger mit sich bringt und keine Veränderung von Attributen stattfindet. Diese Beschränkung der Anforderungen ermöglicht grosse Freiheiten bei der Wahl des Blockings. Beim Graph Blocking wird also mehr Speicherplatz auf dem externen Speicher benutzt, um die Anzahl der I/O Zugriffe und damit die Laufzeit des Algorithmus zu verbessern. Daher ist für die Bewertung von möglichen Blockings auch der zusätzlich verwendete Speicher relevant. Um diesen greifbar zu machen wird betrachtet, um ein wie hohes Vielfaches sich der benötigte Speicher erhöht. Diese Zahl nennt sich Storage-Blowup. Wenn zum Beispiel für das Speichern von N Elementen, die in Blöcken der Größe B gespeichert werden, statt N/B für das Graph-Blocking nun ganze 3N/B Blöcke benötigt werden, so beträgt der Storage-Blowup 3. Um die Worst-Case Performance des Blockings für alle potentiell möglichen Algorithmen zu bestimmen wird bei der Betrachtung der Güte davon ausgegangen, das kein spezieller, vorher bekannter, Algorithmus über den Graph läuft, sondern das als nächstes immer der Knoten ausgewählt wird, der als frühestes einen weiteren Page-Fault verursacht. Der Angreifer kennt also die Strategie des Blockings und wird dieser stetig entgegenarbeiten. Dies erschwert die Suche nach einem optimalen Blocking, dafür ist ein gefundenes in der Praxis später universell einsetzbar. Die beiden Zielgrößen für die Effizienz eines Graphblockings lassen sich nicht gleichzeitig minimieren, da sie einander entgegenwirken. Aus diesem Grund muss für den Einzelfall entschieden werden, welcher der beiden Parameter (Anzahl auftretender Page-Faults oder benötigter Speicherplatz) kritischer ist.

62 Eindimensional Im eindimensionalen Fall, also bei einer Linearen Liste, ist das optimale Blocking trivial, sofern diese nur in eine Richtung durchlaufen werden darf. In diesem Fall müssen einfach alle Knoten nach ihrer Reihenfolge sortiert abgelegt werden. Diese Liste wird nun einfach in die Blöcke aufgeteilt. Bei dem Durchlauf wird nun immer ein neuer Block geladen, wenn einer durchlaufen wurde. In diesem Fall ist der Storage-Blowup 1 und es sind immer B 1 Schritte möglich bis zum nächsten Page-Fault. Sobald die Liste beliebig durchlaufen werden darf, wird es schwieriger: der Angreifer kann nach dem Laden eines neuen Blocks sofort wieder zum vorherigen Knoten zurück und damit einen weiteren Page-Fault (nach nur einem weiteren Schritt) provozieren. Bei ausreichend internem Speicher könnte man alte Blöcke im Speicher behalten. Da dies in unserem Fall nicht gegeben ist (es passt genau ein Block in den Speicher: M = B) wird nun ein verändertes Blocking benötigt. Das Blocking wird nun wie in Abbildung 5 aufgebaut. Die Daten werden einmal komplett kopiert und die Position der Blöcke wird um B/2 nach hinten verschoben. Wenn nun der Graph traversiert wird, fängt man bei den Ursprungsblöcken mit dem Lesen an. Sobald ein Pagefault auftritt, wird der nächste Block allerdings aus der zweiten Partition gelesen. Durch die Verschiebung um B/2 sind in dieser Partition nun der gesuchte anzusprechende Block vorhanden, sowie die B/2 1 Nachfolgerund B/2 Vorgängerknoten des aktuellen Blockes. Daher kann der Angreifer nicht mit dem nächsten Schritt, sondern frühestens in B/2 1 Schritten den nächsten Page Fault provozieren. Der nächste Block wird nun wieder aus der ersten Partition gelesen, wodurch erneut die Verschiebung ausgenutzt wird. Der Storage Blowup für dieses Blocking beträgt 2 und die Zeit bis zum nächsten Page Fault beträgt zu jeder Zeit mindestens B/2 1 Schritte Abbildung 5: Blocking für eine lineare Liste mit einem Storage Blowup von 2 bei Nutzung einer Blockgröße von Bäume Ein effizientes Graph Blocking von allgemeinen Bäumen ist interessanterweise nicht möglich. Es sind hingegen weitere Beschränkungen nötig, die entweder die erlaubte Traversierung des Graphen beschränken oder die Struktur des Baumes selber. Dies resultiert daraus, dass immer ein Baum existieren kann, bei dem nicht alle Kinder eines Knotens in den internen Speicher passen. In dem Fall das die Anzahl der Kinder (hier K genannt) eines Knoten die Bedingung K M erfüllt muss mindestens ein Knoten (der aktuelle muss ja auch im Speicher vorgehalten werden) auf den externen Speicher ausgelagert werden. Da der Angreifer diesen Knoten kennt, kann er nun immer auf den gerade nicht vorgehaltenen Knoten zugreifen und damit in jedem Schritt einen Page Fault verursachen. Aus diesem Grund wird, um beliebige Traversierungen zu erlauben, im Folgenden der Knotengrad eines jeden Knoten des Baumes auf einen konstanten Wert d begrenzt.

63 59 Die Konstruktion des Blockings beginnt analog wie im eindimensionalen Fall mit der Aufteilung in zwei Partitionen. Diese Partitionen werden in Ihrer Höhe im Baum begrenzt. Dazu wird der Baum in mehrere Layer mit einer jeweiligen Höhe von log d B aufgeteilt. Nun gibt es im Graphen Aufteilungen in der Breite und in der Höhe des Baumes. Diese sind in Abbildung 6 dargestellt. 3. A Survey of Techniques for Designing I/O-Efficient Algorithms 57 log d B log d B Fig A blocking of a binary tree with block size 7. The subtrees in the first Abbildung partition6: are Blocking outlined eines with binären dashedbaums lines. The mit einer subtrees Blockgröße in the second von 7. partition Der Storage are Blowup outlined beträgt withhierbei solid lines. maximal 4. Die erste Partition besteht aus den mit gestrichelten Linien umrandeten und die zweite aus den mit den durchgezogenen Linien umrandeten Teilbäumen. (Bildquelle: [1]) shown in Fig. 3.3, we choose one vertex r of T as the root and construct two partitions of T into layers of height log d B (see Fig. 3.4). In the first partition, the Durch i-thdiese layer Aufteilung contains kann all vertices man nun at bestimmen, distance between welche(iknoten 1) log in d Bwelchen and Layern i log d und B Partitionen 1 from r. Invorhanden the secondsind. partition, Ausgehend the i-th vonlayer der Wurzel contains r gesehen all vertices sind dasat indistance der erstenbetween Partition (i im 1/2) i-tenlog Layer d B and die Knoten (i + 1/2) mit log Entfernungen d B = 1 fromimr. Bereich Each von layer (i in1) both log d partitions B bis zu i consists log d B of 1. subtrees In der zweiten of size at Partition most B, sind so that es imeach i-ten Layer subtree die Knoten can bemit stored Entfernungen in a block. von Moreover, (i 1/2) small log d subtrees B bis zu (i+1/2) can be packed log d into 1B. blocks Jeder Layer so that besitzt no block alsoisteilbäume less than in half einer full. Größe Hence, von both maximal partitions B - together kann also problemlos use at most in einem 4N/Block blocks, gespeichert and thewerden. storageein blow-up Problem is atbesteht most four. in den Fransen des Baumes, The paging die sehr algorithm kleine now Teilbäume alternates seinbetween können. the In diesem two partitions Fall können similar mehrerethe Teilbäume above paging gemeinsam algorithm in einem for lists. Block Consider gespeichert the traversal werden, of uma einen path, Füllgrad and let to vonv mindestens be a vertexmehr that als causes der ahälfte pageeines fault. Blockes Assumezu that erreichen. the treemit currently diesen Parameternmain ergibt memory sich, dass is from zumthe Speichern first partition. der beiden Then Partitionen v is the root maximal or a leaf 4N/B of ablöcke tree held in benötigt in thewerden, first partition. was einem Hence, Storage-Blowup the tree in the vonsecond 4 entspricht. partition that contains v contains Damit wurde all vertices allerdings thatnoch can be nichts reached überfrom die Effizienz v in (log d dieses B)/2 Blockings 1 steps. Thus, ausgesagt. byum loading diese this zu Berechnen block intoistmain das Verhalten memory, beim the algorithm Laden derguarantees Blöcke relevant. that the Hier wirdnext wieder pagebei fault jedem occurs Lesezugriff after atzwischen least (logden d B)/2 einzelnen 1 steps, Partitionen and traversing gewechselt. a Daspath Verhalten of length des LSystems causeskann at most am besten 2L/(log bei d B) Betrachtung page faults. von Abbildung 6 nachvollzogen If all werden. traversed Angenommen paths areder restricted aktuelleto Knoten travel ist away v, ein frombeliebiger the rootknoten of T, desthe Graphen, storageund blow-up hat beim canzugriff be reduced soeben toeinen two, Page and the Fault number verursacht. of page Diefaults aktuellen can Daten be reduced werden to als L/ auslog Partition d B. To 1see gelesen this, angenommen observe that (der onlyfall the für first Partition of the 2 ist above analog). partitions Dann befindet is needed, sichand dieser forknoten any traversed im Speicher path, und thestellt vertices entweder causing die Wurzel page dieses faults Teilbaumes are the roots dar of oder subtrees befindet in the sich partition. als Blatt am After Ende loading des Teilbaumes. the block Wenn containing nun der gewünschte that root into Knoten main aus memory, der zweiten log Partition d B 1 steps geladen are wird, necessary so befindet sich dieser nicht am Rand des geladenen Teilbaumes, sondern mitten drin. Es in order to reach a leaf of the subtree, and the next page fault occurs after sind dadurch mindestens weitere (log log d B steps. For traversals towards d B)/2 1 Schritte möglich, bis zum nächsten the root, Hutchinson et al. [419] show Page-Fault (am Rand der zweiten Partition). Damit lässt sich ein Pfad der Länge that using O(N/B) disk blocks, a page fault occurs every Ω(B) steps, so that L mit maximal 2L/(log a path of length L can d B) Page Faults traversieren. be traversed in O(L/B) I/Os. Wenn man nun den Pfad beschränkt, der durch den Baum genommen werden kann, so sind Einsparungen beim Storage-Blowup möglich. Angenommen ausgehend von der Wurzel des ganzen Baumes sind nur Traversierungen erlaubt, welche sich von der Wurzel entfernen. Dann kann man den Baum traversieren, ohne die zweite

64 60 Partition nutzen zu müssen. Wenn hier beim Laden des Knoten v ein Page Fault auftritt, so kann dieser beruhigt aus der ersten Partition gelesen werden, da die zweite nur für Schritte zurück in Richtung Wurzel Vorteile bringen würde. In diesem Fall hat das Nutzen nur der ersten Partition den weiteren Vorteil, dass der Knoten v immer die Wurzel eines neuen Teilbaumes darstellt. Dadurch sind in Richtung der Blätter des Teilbaumes mehr Schritte möglich, als es in der zweiten Partition wären. Dies sind hier nämlich log d B 1 Schritte statt (log d B)/2 1. Da der nächste Page Fault also erst nach log d B Schritten auftritt und eine Partition eingespart wird kann der Storage Blowup auf 2 reduziert werden bei gleichzeitiger Reduzierung der Page Faults für einen Weg der Länge L auf L/log b B. Für den umgekehrten Weg von den Blättern in Richtung der Wurzel lässt sich sogar eine maximale Anzahl von O(L/B) I/O-Zugriffen zeigen. 3.3 Zweidimensionale Gitter Das Blocken von zweidimensionalen Gittern hat viel Ähnlichkeit mit dem Verfahren zum Blocken von Listen. Dies resultiert daraus, dass eine lineare Liste im Grunde ein Spezialfall von allgemeinen Gittern, nämlich ein eindimensionales Gitter, ist. Beim zweidimensionalen Gitter wird das Gitter mit kleinen Sub-Gittern überzogen. Diese Sub-Gitter, Tesselation 3 genannt, werden in einer Größe von B B = B erzeugt Abbildung 7: 2D Graph.Blocking mit 2 Tesselationen Wenn M = B ist, ist es nicht möglich mit zwei Tesselationen auszukommen. Gegeben sind zwei Tesselationen, um die Daten zu überdecken. Diese beginnen mit einem Offset von k (auf der X-Achse) und einem Offset von l (auf der Y-Achse) von den Originaldaten ausgehend gesehen. Ferner werden die folgenden vier Punkte genauer betrachtet: (i B + k, j B), (i B + k + 1, j B), (i B + k, j B + 1) und (i B + k + 1, j B + 1). Diese können auf einem Pfad des Angreifers liegen - es liegen allerdings maximal zwei dieser Punkte gemeinsam in einer Tesselation (siehe Abbildung 7). Nun ist es für einen Angreifer möglich, immer den nicht im Speicher vorgehaltenen Knoten anzuspringen und damit in jedem Schritt einen Page Fault zu verursachen. 3 Mosaik

65 61 Mit der Nutzung von drei Tesselationen ist es möglich eine Verringerung der Page Faults zu erreichen. Um diese anzuordnen bekommen die Tesselationen eine Verschiebung von B/3 zueinander. Die Erste beginnt also ohne Verschiebung, die Zweite mit einem Offset von B/3 und die Dritte mit einer Verschiebung von 2 B/3. Dies gilt für beide Achsen und ist in Abbildung 8 visualisiert. Durch diese neue Anordnung kann nun für jeden Knoten eine passende Tesselation gefunden werden, damit der Knoten mindestens einen Abstand von B/6 zum Rand der neu geladenen Tesselation hat. Der Paging Algorithmus sucht nun jeweils die passende Tesselation aus und kann B/6 weitere Schritte garantieren. Das Ergebnis ist das Auftreten von Page Faults maximal alle B/6 Schritte im Graphen. Der Storage- Blowup beträgt hierbei durch die drei Tesselationen: A Survey of Techniques for Designing I/O-Efficient Algorithms 59 Fig A blocking for M = B. Abbildung 8: 2D Graph-Blocking mit 3 Tesselationen (Bildquelle: [1]) Finally, if M 3B, the storage blow-up can be brought down to one while keeping the number of page faults incurred by the traversal of a path Sofern length dochl mehr at 4L/ interner B. ToSpeicher achieve this, zur Verfügung the tessellation stehen shown sollte, in Fig. so kann 3.6 is used. To prove that the traversal of a path of length L incurs most 4L/ das System weiter verbessert werden - sowohl in Hinsicht auf Speicherplatz, B page faults, we show that most two page faults can occur within als auch auf die Zahl der benötigten I/O-Operationen. Wenn der interne Speicher mehr B/2als doppelt steps. so viel So assume Platz bietet, the opposite. also MThen 2B, letergibt v be asich vertex folgende that causes Situation: a page fault, Das andgitter let u be wird thewieder vertexvon visited zwei immediately Tesselationen before überdeckt v. Let u(siehe be in the auchsolid Abbildung bold 7). Das subgrid oben in beschriebene Fig. 3.6 andproblem assume that wurde it is verursacht in the top left quarter of the subgrid. Then all vertices that can be reached from u durch das Verdrängen des aktuellen Blocks aus dem Speicher. Nun kann aber ein zusätzlicher B/2 steps are Block, contained nach in dem folgenden the solid Schema, thin subgrids. im internen In particular, Speicher vorgehalten v is contained in one of these subgrids. If is in subgrid A, it takes at least werden: Betrachtet wird der Knoten v der soeben, auf dem Pfad vom Knoten B/2 steps u kommend, after visiting v to reach a vertex in subgrid C. If v is in subgrid C, it takes at least einen Page Fault verursacht hat. Nun ist es wichtig den Block mit dem KnotenB/2 u gleichzeitig steps afterim Speicher visiting zu behalten v to reach und a vertex den anderen in subgrid Block A. zu Hence, verwerfen. in both cases only a vertex in subgrid B can cause another page fault within Gleichzeitig muss beim Laden des neuen Blockes darauf geachtet werden, die korrekte B/2 steps Tesselation after visiting zu laden. Es ergibt vertex sich u. die If vsituation, is in subgrid dassb, einer consider the next vertex w after v that causes a page fault and is at most beiden Blöcke (die den Knoten v enthalten) zusammen mit dem Block des B/2 Knotens stepsu away einenfrom B/4 u. großen Vertex Teilbereich w is either um in v herumsubgrid abdeckt, A, or in subgrid C. W.l.o.g. let w be in subgrid A. Then takes at least mit der Folge, dass nun mindestens B/4 weitere Schritte bis zum nächsten Page Fault B/2 steps möglich to reach sind. a vertex in subgrid C, so that again only two page faults can occur within Also hat man beim Durchlaufen eines Pfades der Länge L maximal 4L/ B Page Faults B/2zu steps erwarten. after visiting u. This shows that the Das traversal Ergebnis of aist path eineof Reduzierung length L incurs desat Storage-Blowup most 4L/ B page auf 2faults. bei gleichzeitiger Verbesserung Blockingder Planar Schrittanzahl Graphs. bis The zumfinal nächsten result Page we Fault discuss auf here mindestens concerns the B/4. Falls blocking der interne of planar Speicher graphs of sogar bounded in einer degree. Grösse Quitevon surprisingly, M 3Bplanar zur Verfügung graphs steht, allow so istblockings eine weitere withverbesserung the same performance des Storage-Blowup as for trees, up möglich. to constant factors. That is, with constant storage blow-up it can be guaranteed that traversing a path of length L incurs at most 4L/ log d B page faults, where d is the maximal degree of the vertices in the graph. To achieve this, Agarwal et al. [7] make use of separator results due to Frederickson [315]. In particular,

66 62 A B u C Abbildung 9: Graph-Blocking mit Storage-Blowup von 1 und B/4 garantierten Schritten (bei M 3B internem Speicher) 3.4 Planare Graphen Ein effizientes Blocking für einen planaren Graphen resultiert in Ergebnissen, die den vorangegangen der Bäume sehr ähneln: die Güte unterscheidet sich nur in konstanten Faktoren. Wichtig ist hierbei wieder, dass die Graphen nur einen festen maximalen Knotengrad besitzen dürfen. Der Ansatz baut auf einem Beweis von Frederickson (in [3]) auf in welchem bewiesen wird, dass sich in jedem planaren Graphen G eine Gruppe S von Knoten (mit Anzahl O(N/ B)) finden lässt, so dass bei Betrachtung von G S keine Zusammenhangskomponenten existieren, welche größer sind als ein Block B. Auf Basis dieses Wissens kann nun ein gutes Blocking in folgender Art zusammengebaut werden: Es wird als Erstes für jede Zusammenhangskomponente aus G S ein einzelner Block vorgesehen. Falls sich beim Erstellen herausstellt, dass Blöcke zu weniger als 1/2 gefüllt sind, so werden mehrere Zusammenhangskomponente in einem Block zusammengefasst. Durch dieses Verfahren wird eine maximale Anzahl von 2N/B Blöcken benötigt. Als Nächstes müssen die von den Knoten in S aus erreichbaren weiteren Knoten gespeichert werden. Hierbei werden für jeden Knoten v S die Knoten der nächsten Nachbarschaft gespeichert. Nämlich genau alle, die in wenigen Schritten erreichbar sind und gemeinsam in einen Block passen. Dies lässt sich durch eine Beschränkung auf (log d B)/2 Schritte Entfernung vom Knoten erreichen.bei (log d B)/2 Schritten sind maximal d (logdb)/2 = B neue Knoten von jedem Knoten aus erreichbar. Hier findet wieder das Verpacken von zu kleinen Teilen gemeinsam in einen Block statt, so dass ein Füllgrad von mehr als 50% auch für diese Blöcke garantiert werden kann. Hieraus ergibt sich ein Storage- B S ) B ) Blowup von nur O(1), da der Gesamtbedarf im zweiten Teil des Blockings O( beträgt, was O(N/B) entspricht. Nun fehlt noch die Garantie der Schritte bis zum nächsten Page Fault, um die Güte von maximal 4L/ B Page Faults entlang eines Pfades der Länge L zu erreichen, welche der Güte entspricht, die für 2-dimensionale Gitter bereits gezeigt wurde. Während der Angreifer den Pfad traversiert und sich im Knoten u aufhält verursacht er einen Page Fault beim Zugriff auf den Knoten v. Für diesen Knoten können

67 63 nun zwei Fälle aufteten: 1. v S 2. v G S Angenommen der Knoten befindet sich nicht in S, so wird die Zusammenhangskomponente mit dem Knoten v in den Speicher geladen. Nun sind beliebige Bewegungen innerhalb dieser Zusammenhangskomponente möglich. Sobald diese verlassen wird, tritt ein Page Fault auf. Dieser Knoten w muss sich nun in dem Set S befinden. Also wird der Block mit den in (log d B)/2 Schritten erreichbaren Nachbarknoten geladen. In diesem Szenario sind nun 2 Page Faults aufgetreten und die geladenen Daten lassen mindestens (log d B)/2 Schritte bis zum nächsten Page Fault zu. Dies sind also für einen Pfad der Länge L maximal 4L/ B Page Faults. Literatur [1] A Survey of Techniques for Designing I/O-Efficient Algorithms, Anil Maheshwari und Norbert Zeh, in: Algorithms for Memory Hierarchies: Advanced Lectures, Serie: Lecture Notes in Computer Science, Springer Berlin / Heidelberg, Volume 2625/2003, Seiten 36-61, [2] External-memory graph algorithms, Yi-Jen Chiang and Michael T. Goodrich and Edward F. Grove and Roberto Tamassia and Darren Erik Vengroff and Jeffrey Scott Vitter, in: SODA 95: Proceedings of the sixth annual ACM-SIAM symposium on Discrete algorithms, Pages , Society for Industrial and Applied Mathematics, 1995, ISBN: [3] Fast algorithms for shortest paths in planar graphs, with applications, Greg N. Frederickson, SIAM J. Comput., Volume 16, Number 6, 1987, p [4] Deterministic parallel list ranking, R. J. Anderson and G. L. Miller, VLSI Algorithms and Architectures, Springer-Verlag, 1988, pages 81-90, ISBN: [5] I/O-efficient algorithms for contour-line extraction and planar graph blocking, Agarwal, P. K., Arge, L., Murali, T. M., Varadarajan, K. R., and Vitter, J. S. 1998, In Proceedings of the Ninth Annual ACM-SIAM Symposium on Discrete Algorithms (San Francisco, California, United States, January 25-27, 1998). Symposium on Discrete Algorithms. Society for Industrial and Applied Mathematics, Philadelphia, PA, [6] Dr. Amitava Datta, Uni Freiburg, Parallel Algorithms and Applications, SS 2001,Lecture 5 vorlesung-uebungen/

68 64

69 65 I/O eziente Algorithmen auf dünnen Graphen 1 Einleitung Christoph Horstmann Vor allem in der Umgebung des Internets kann es heute zu sehr groÿen Datenmengen kommen. So kann es zum Beispiel sein, dass eine Suche in einem Graphen mit 200 Millionen Knoten und 2 Milliarden Kanten stattndet. Graphen dieser Gröÿe können nicht mehr im Hauptspeicher eines Systems gehalten werden. Es müssen also Algorithmen genutzt werden, welche solche Graphen I/O ezient bearbeiten. Im folgenden werden nun einige solcher Algorithmen vorgestellt, welche auf eine speziellen Klasse von Graphen arbeiten, den sogenannten dünnen Graphen. 1.1 Graph Klassen Dünn Planar Ein Graph G = (V, E) gilt als dünn, wenn die Anzahl der Kanten E nur linear zu der Anzahl der Knoten V wächst, also E = O( V ) gilt. In Abbildung 1 werden die ver- Outerplanar begrenzte Baumweite Gitter Abbildung 1: Unterklassen von dünnen Graphen schiedenen Unterklassen der dünnen Graphen gezeigt. Beispiele für einzelne Graphklassen werden in Abbildung 2 gezeigt und im folgenden erklärt. Bei einem planaren Graphen handelt es sich um einen Graphen, welcher so gezeichnet werden kann, dass keine Kante sich mit einer anderen kreuzt. Diese Zeichenart wird planare Einbettung genannt. Der linke Graph in Abbildung 2 ist also planar, da, wie darunter zu sehen, alle Kanten so gelegt werden können, dass sich keine Kante mit einer anderen kreuzt. Ein Outerplanarer Graph besitzt, zusätzlich zur Eigenschaft, dass er planar ist, noch die Bedingung, dass sich alle Knoten auf dem äuÿeren Rand der planaren Einbettung

70 66 planar outerplanar Gitter Abbildung 2: Beispiele für einige Graphklassen benden. Es verläuft hier also keine Kante auÿerhalb dieses Knoten-Randes. Der planare Graph aus Abbildung 2 ist zum Beispiel nicht outerplanar, da es bei der planaren Einbettung unten, nicht möglich ist, dass alle Kanten innerhalb der Knoten verlaufen. Von Gitter Graphen ist es die Haupteigenschaft, dass die Knoten eine Gitter-Struktur bilden. Kanten sind hier nur von einem Knoten zu seinen Nachbarn zugelassen. Daraus ergibt sich, dass ein Knoten nur maximal acht Kanten zu seinen Nachbarn haben kann. 1.2 Algorithmen I/O ezient gestalten Eine Möglichkeit um einen Algorithmus für ein Problem I/O Ezient zu gestalten ist es, bestimmte Eigenschaften einer Graphklasse auszunutzen. So läÿt sich zum Beispiel die Eigenschaft von Gittergraphen ausnutzen, dass jeder Knoten nur maximal acht Nachbarn haben kann. Ein weiteres Verfahren ist die Kontraktion eines Graphen. Hier wird der Ausgangsgraph zuerst in mehreren Schritten kontrahiert, so dass ein Graph entsteht, auf dem das Problem mit sehr wenigen I/O Schritten gelöst werden kann. Beim Kontrahieren wird im allgemeinen so vorgegangen, dass in einem Schritt mehrere Kanten aus dem Ausgangsgraphen zum Kontrahieren ausgewählt werden. In Abbildung 3 ist zu sehen, was bei , Abbildung 3: Kontrahieren eines Knotens dem Kontrahieren einer Kante passiert. Die Kante zwischen Knoten 1 und 2 soll kontrahiert werden. Hierzu werden die beiden Knoten zu einem zusammengefasst. Alle Kanten, welche von Knoten 1 oder 2 zu einem anderen Knoten des Graphen verliefen, werden nun Kanten zu dem zusammengefassten Knoten. Diese Kanten-Kontraktionen werden in einem Kontraktions-Schritt auf einem Graphen G in der Regel auf vielen Kanten ausgeführt, so dass der Ergebnis-Graph nach einem Schritt nur noch eine Gröÿe von O( G 2 )

71 67 besitzt. Hierbei ist N = G die Anzahl der Knoten in G. Die Gröÿe des Graphen nach der Kontraktion halbiert sich also asyptotisch im Gegensatz zum Ausgangs-Graphen. Nach mehreren dieser Kontraktions-Schritte ergibt sich dann ein Graph, auf dem das eigentliche Problem in wenigen I/O Schritten gelöst werden kann, da dieser Graph deutlich weniger Knoten besitzt, als der Ausgangs-Graph. Als nächstes muss dann die Lösung für den kontrahierten Graphen wieder schrittweise auf die vorherigen Kontraktions-Schritte übertragen werden. Hierzu werden im Allgemeinen die einzelnen Kontraktionen eines Schrittes wieder rückgängig gemacht. Allerdings ist die Erweiterung der Lösung auch sehr speziell von dem Problem abhängig. Wie so etwas in einem konkreten Algorithmus abläuft wird in Abschnitt gezeigt. 1.3 Abhängigkeiten der Algorithmen In dieser Arbeit werden exemplarisch Algorithmen vorgestellt, welche verschiedene Probleme auf Planaren Graphen lösen. Einige dieser Algorithmen haben das Ergebnis eines anderen Algorithmus als Voraussetzung, da sie mit Hilfe dessen Lösung ihr eigenes Problem lösen. Weiterhin gilt bei diesen Abhängigkeiten, dass der Algorithmus eine bestimmte Laufzeitschranke nur einhalten kann, wenn auch der Algorithmus, dessen Lösung benötigt wird eine bestimmte Laufzeitschranke einhält. So ergab sich für mehrere Algorithmen auf planaren Graphen eine gegenseitige Abhängigkeit[1], da alle Algorithmen die Lösung eines anderen Algorithmus benötigen, um eine bestimmte Laufzeit einzuhalten. Diese Abhängigkeiten werden in Abbildung 4 dargestellt. Es zeigt jeweils ein Pfeil von Tiefensuche Breitensuche Graph Partitionierung Kürzeste Wege Abbildung 4: Abhängigkeiten verschiedener Algorithmen auf planaren Graphen von einem Problem zu einem anderen, wenn dieses Problem eine Lösung des anderen Problems benötigt. Um also zum Beispiel das Kürzeste Wege Problem auf planaren Graphen zu lösen, wird eine eine Partitionierung dieses Graphen benötigt. Um diese Abhängigkeiten aufzubrechen muss also eines der genannten Probleme, ohne die Lösung eines anderen Probelms zu verwenden, gelöst werden. Der Durchbruch hierzu wurde 2001 von Maheshwari und Zeh erreicht[2]. Hier wurde ein Algorithmus entwickelt, welcher eine optimale h Partition(siehe Abschnitt2 ohne die Lösung eines anderen Problems berechnet werde konnte. Im Folgenden geht es nun um diesen Algorithmus zur Berechnung einer Partitioin eines planaren Graphen. Danach wird ein Algorithmus vorgestellt, welcher auf einem planaren Graphen das Kürzeste Wege Problem löst. Zuletzt wird dann ein Algorithmus gezeigt, der eine Tiefensuche auf einem planaren Graphen durchführt.

72 68 2 Partition von Graphen In diesem Abschnitt geht es um einen Algorithmus, welcher eine optimale h Partition eines Graphen G berechnet. Um eine h Partition zu nden wird eine Menge von Knoten S gesucht, welche, wenn sie entfernt wird, den Graphen G in k = O(N/h) Teilgraphen G 1,..., G k aufteilt. Jeder dieser Teilgraphen enthält höchstens h Knoten. Die Knoten in der Menge S sind nicht Teil dieser Teilgraphen. Die Partition mit Seperator und Teilgraphen wird als P = (S, {G 1,..., G k }) geschrieben. Abbildung 5 zeigt ein Beispiel h Partition optimale h Partition G G 2 S G 1 G 2 S G 1... G 3... G 3 N Knoten O(N/h) viele Teilgraphen mit maximal h Knoten zusätzlich in der Größe beschränkt Abbildung 5: Beispiel für eine Partition eines Graphen für die Partition eines beliebigen Graphen G mit N Knoten. In dem mittleren Teil ist die Partition des Graphen mit den entsprechenden Eigenschaften zu sehen. Es gibt hier O(N/h) Teilgraphen, welche durch den Seperator S getrennt werden. Diese Teilgraphen besitzen maximal h Knoten. Eine optimale h Partition stellt, zusätzlich zu den bisher Genannten, noch eine Bedingung an die Gröÿe des Seperators S. Der Seperator muss bei einer optimalen Partiotion so groÿ sein, wie das Maximum über die minimalen Seperatoren von allen N Knoten groÿen Graphen welche zu einer bestimmten Graph Klasse gehören. In diesem Fall ist die Graph Klasse die Klasse der planaren Graphen. Formal wird zur Begrenzung der Gröÿe von S festgelegt: σ(c, N, h) = max G min S { S : Seperator S erzeugt eine h Partition von G}. Es muss dann S = O(σ(C, G, h)) gelten. S darf also nur asymptotisch so viele Knoten enthalten, wie der maximale kleinste Seperator eines Graphen aus der Klasse C mit G vielen Knoten. 2.1 Planare Graphen Um eine optimale h-partition auf Planaren Graphen zu nden, wurde von Maheshwari und Zeth[2] ein Algorithmus vorgestellt, welcher keine vorherige Bearbeitung durch einen anderen Algorithms benötigt. So kann dieser Algorithmus als Ausgang für weitere Algorithmen verwendet werden, welche als Teil ihrer Lösung eine h Partition ihres Eingabe-Graphen bilden. Weiterhin nutzt dieser Algorithmus das grundsätzliche Prinzip der Graph-Kontraktion, um so den Algorithmus I/O ezient ausführen zu können. Das grundsätzliche Vorgehen des Algorithmus ist es zuerst den Eingabegraphen G

73 69 in mehreren Schritten zu kontrahieren. Die in jedem kontraktions-schritt entstehenden Graphen werden im folgenden H 0,..., H r genannt. Hierbei gilt einmal G = H 0 und H i H i, was bedeuted, dass der Graph H i+1 weniger oder genau halb so viele Knoten enthalten muss, wie H i. Diese Kontraktionen werden r = log(b) mal ausgeführt, da so der der Graph H r die Gröÿe H r = O(N/B) erreicht. Als nächstes wird auf H r ein Algorithmus angewandt, welcher eine optimale Partition, mit den Seperator-Knoten S r, berechnet. Ein solcher Algorithmus wird in [3] vorgestellt. Dieser Algorithmus benötigt zur Berechnung eines h Seperators auf einem Graphen mit N Knoten O(N ) Schritte. Da H r nur O(N/B) Knoten besitzt, ergibt sich also für diesen Algorithmus eine Laufzeit von O(N/B) = O(scan(N)) I/O Schritten. Zuletzt wird dann über die Graphen H r 1,..., H 0 iteriert um jeweils die Partition von H i aus H i+1 zu berechnen. Hier wird von den Knoten S i, welche die Knoten sind, die in H i+1 zu S i+1 kontrahiert waren, ausgegangen. S i induziert schon eine Partition von H i. Diese wird nun noch weiter verfeinert, indem weitere Seperator-Knoten zu S i hinzugefügt werden. Im Folgenden wird nun erklärt, wie zuerst der Graph G zu dem Graphen H r kontrahiert wird. Danach wird vorgestellt, wie der auf H r gefundene Knotenseperator S r schrittweise zu einem Seperator S für G erweitert wird Kontrahieren des Graphen Zuerst werden für die einzelnen Knoten folgende Eigenschaften fest gelegt. Das Gewicht eines Knotens ω(v) stellt die Anzahl der Knoten dar, welche der Knoten v aus G repräsentiert. Die Gröÿe σ(v) gibt an, wie viele Knoten aus H i 1 zu dem Knoten v kontrahiert wurden. ω(v) σ(v) v H 0 p 0 =2 H' 1 p 1 =4 H'' 1 p 1 =4 H 1 p 1 =4 4 4 s 1 1 l l 1 1 l 4 4 s s 4 4 (a) (b) (c) (d) Abbildung 6: Beispiel für Kontraktion des Graphen In Abbildung 6 ist ein Beispiel für den ersten Schritt der Kontraktion eines Graphen zu sehen. Der Ausgangsgraph ist unter (a) zu sehen. Für den ersten Kontraktions-Schritt wird zuerst von H 0 = G ausgegangen. In H 0 gilt ω(v) = 1 für alle Knoten. In Abbildung 6 (a) und (b) wird ω(v) der Übersicht wegen nicht dargestellt. Um nun aus H i, H i+1 zu

74 70 berechnen wird zuerst ein Zwischengraph H i+1 = H i gebildet (Schritt (b) in Abbildung 6). In diesem Graphen gilt σ(v) = 1 und ω(v) H i+1 = ω(v) Hi für alle v H i+1. Aus dem Graphen H i+1 wird nun der Graph H i+1 gebildet, indem so lange Kanten {v, w} kontrahiert werden, wie noch eine Kante {v, w} existiert, für welche gilt: ω(v) + ω(w) p i+1 oder σ(v) + σ(w) 56. p i = 2 i+1 stellt hierbei einen Grenzwert für das maximale Gewicht ω eines Knotens für den jeweiligen Iterationsschritt dar. Die 56, welche die Obergrenze für σ(v) + σ(w) ist, ergibt sich aus den Eigenschaften von Planaren Graphen. In Abbildung 6 ist unter (c) das Ergebnis dieser Kontraktionen zu sehen. Es wurden jeweils die in (b) markierten Kanten, zu dem verbundenen Knoten in (c), kontrahiert. Die Reihenfolge der Kontraktionen ist nicht deterministisch und wurde für das Beispiel so gewählt. Als nächstes werden die Knoten aus H i+1 in leichte und schwere Knoten eingeordnet. Ein Knoten v ist schwer, wenn gilt: ω(v) p i /2 oder σ(v) 28. In Abbildung 6 sind in (c) die Knoten entsprechend mit l für leicht und s für schwer markiert. In H i+1 können keine zwei leichte Knoten nebeneinander liegen, da die Kante zwischen zwei leichten Knoten noch die Voraussetzungen für eine Kontraktion erfüllen würde, es aber alle Kanten, welche diese Voraussetzungen erfüllen schon vorher kontrahiert wurden. Die leichten Knoten, dessen Grad höchstens 2 ist, werden jetzt in Klassen C 1,..., C q eingeteilt. Und zwar so, dass alle Knoten einer Klasse die gleiche Gruppe von Schweren Knoten als Nachbarn haben. Weiterhin werden die Knoten einer Klasse C j in eine minimale Anzahl von Unterklassen C j,1,..., C j,kj aufgeteilt. Für das Gewicht jeder Unterklasse C j,l mit 1 l k j muss ω(c j,l ) p i+1 und für die Gröÿe muss σ(c j,l ) 56 gelten. In Abbildung 6 werden in (c) die Knoten markiert, welche zu einer Klasse zusammengeführt werden können. Da diese Knoten zusammen nicht die Grenzwerte überschreiten gehören sie auch zu einer Unterklasse. Der nale Graph H i+1 wird nun aus H i+1 wie folgt konstruiert. Einmal besteht er aus allen schweren Knoten und allen Leichten Knoten dessen Grad gröÿer als 2 ist. Das Gewicht dieser Knoten wird aus H i+1 übernommen. Weiterhin wird H i+1 jeweils ein Knoten für jede Klasse C j,l der leichten Knoten hinzugefügt. Hier ist das Gewicht eines Knotens die Summe der Gewichte der Knoten in C j,l. Der Graph H i+1 ist in Abbildung 6 in (d) zu sehen Laufzeit des Kontrahierens Für das Finden der kontrahierbaren Kanten in H i+1 benötigt der Algorithmus O(sort( H i+1 )) Schritte, da alle Kanten danach geordnet werden müssen, ob sie kontrahierbar sind. Um die leichten Knoten in die Klassen zusammenzufassen muss der Graph auch einmal sortiert werden, was O(sort( H i+1 )) Schritte benötigt. Da H i+1 höchstens so viele Knoten besitzt wie H i+1 und H i+1 = H i ist lässt sich die Laufzeit eines Kontraktoinsschrittes mit O(sort( H i )) abschätzen. Da bei der ersten Kontraktion von G = H 0 ausgegangen wird und sich die Gröÿe des Graphen in den einzelnen Kontraktionsschritten geometrisch verringert(die Grenze p i verdoppelt sich in jedem Schritt), kann die Laufzeit des Kontrahierens mit O(sort( G )) abgeschätzt werden.

75 Erweitern des Seperators Auf dem Graphen H r kann nun ein Knotenseperator gebildet werden, welcher den Graphen in Teilgraphen der Gröÿe h log(b) aufteilt und dabei einen Seperaotr S erzeugt, welcher O(N/ h) Knoten enthält. Um nun den Seperator auf die vorhergehenden Kontratktionsschritte zu übertragen wird von einem Schritt H j+1 mit den Seperatorknoten S j+1 ausgehend der Seperator des Schrittes H j gebildet. Zuerst werden die Kontraktionen aller Knoten aus S j+1 rückgängig gemacht, welche bei dem Kontrahieren von H j auf H j+1 durchgeführt wurden. Die so entstehende Knotenmenge wird als S j bezeichnet. Diese Knotenmenge ist der vorläuge Seperator des Graphen H j. In Abbildung 7 ist dieser Schritt an einem Beispiel H j+1 H j S' j S j+1 Abbildung 7: Erweitern des Seperators dargestellt. Weiterhin sind die dort im Graphen H j die einzelnen, durch den Seperator erzeugten, Teilgraphen zu sehen(gestrichelt umrahmt). Weiterhin kann es vorkommen, dass die Teilgraphen in H j gröÿer als eine bestimmte Grenze sind. In diesem Fall wird der entsprechende Teilgraph so weit aufgeteilt, dass jeder Teil diese Grenze unterschreitet. Die zusätzlichen Seperatorknoten, welche dabei entstehen, werden dem bisherigen Seperator S j hinzugefügt. Hieraus ergibt sich dann der endgültige Seperator S j für den Schritt H j. Diese Schritte werden für alle Kontraktionsschritte H r,..., H 0 durchgeführt. So entsteht dann ein Seperator für den Graphen H 0, welcher gleich dem Eingabegraphen G ist. Dieser Seperator muss aber noch nicht zwangsläug G in Teile aufteilen, dessen Gröÿe kleiner oder gleich h ist. Hierzu wird jeder Teilgraph von G, welcher durch den Seperator S 0 entsteht in Teilgraphen partitioniert, welche kleiner oder gleich h groÿ sind. Weiterhin müssen die so entstandenen Teilgraphen so umgeformt werden, dass sie nur h viele Knoten als Nachbarknoten im Seperator S haben Laufzeit des Erweiterns Um den erweiterten Seperator S i aus S i+1 zu konstruieren werden O(sort( H i )) Schritte benötigt, da die entsprechenden Knoten in H i gefunden werden müssen. Für die Partitionierung der entstehenden Teilgraphen, werden O(scan( H i )) Schtitte benötigt, da jeder Teilgraph mit konstanter Zahl von I/O Operationen in den Speicher eingelesen werden

76 72 kann, da die Teilgraphen eine bestimmte Gröÿe nicht überschreiten. Insgesamt ergibt sich also für das Erweitern eine Laufzeit von O(sort(N)), da der gröÿte Schritt H 0 = G ist. Für den Gesamten Algorithmus, ergibt sich dann eine Laufzeit von O(sort(N)), da das Kontrahieren, das Finden des Seperators auf dem kontrahierten Graphen und das Erweitern des Seperators jeweils O(sort(N)) I/O Operationen benötigen. 3 Kürzeste Wege und Breitensuche Bei dem Kürzeste Wege Problem geht es darum die kürzesten Wege von einem Ausgangsknoten s zu allen anderen Knoten des Graphen zu nden. In Abbildung 8 ist links s s Abbildung 8: Beispiel für Kürzeste Wege ein Beispiel für einen Graphen zu sehen, auf welchem das Kürzeste Wege Problem gelöst werden soll. Die Kanten des Graphen sind in diesem Fall gewichtet mit der Länge des Weges über die entsprechende Kante. Auf dem Graphen rechts sind dann die kürzesten Wege zu den einzelnen Knoten, von der Quelle s aus, eingezeichnet. Bei der Breitensuche werden von dem Quellknoten s ausgehend die Nachbarknoten gesucht. Hierbei werden die direkten Nachbarn von s besucht, danach die Knoten, welche zwei Kanten von s entfernt sind und so weiter. Die Einzelnen Knoten werden dann mit der kürzesten Entfernung zu s markiert. Aufgrund dieser Eigenschaften ist es ein s s Abbildung 9: Beispiel für Breitensuche fach die Breitensuche auf das Kürzeste Wege Problem zu reduzieren. Dazu müssen die Kantenlängen im Graphen alle auf das Gewicht 1 gesetzt werden. In Abbildung 9 ist ein Beispiel hierfür zu sehen. Werden jetzt auf diesem Graphen die kürzesten Wege gesucht,

77 73 so enthält jeder Knoten, als Entfernung zu s genau die Anzahl von Kanten, die er von s entfernt ist, da jede Kante nur das Gewicht 1 besitzt. Dies ist in Abbildung 9 rechts zu sehen. 3.1 Planare Graphen und Gitter Graphen Der folgende Algorithmus kann das Kürzeste Wege Problem auf Gitter und Planaren Graphen lösen. Eingabe ist hier der Graph G = (V, E) sowie ein Ausgangsknoten s von welchem aus die küzesten Wege gesucht werden sollen. Um eine I/O eziente Lösung des Problem zu erreichen teilt der Algorithmus den Graphen zuerst in mehrere Teilgraphen auf. Auf diesen Teilgraphen kann mit wenigen I/O Schritten eine Teillösung berechnet werden. Im nächsten Schritt wird dann jeder Teilgraph auf wenige Knoten reduziert. Aus diesen Knoten wird ein, im Gegensatz zu G reduzierter Graph gebildet, auf dem das Kürzeste Wege Problem gelöst werden kann. Zuletzt muss noch die Lösung auf dem reduzierten Graphen mit der Lösung der Teilgraphen zu einer Gesamtlösung des Problems kombiniert werden. Wie sich die einzelnen Schritte im Detail gestalten wird im Folgenden erklärt. Um den Graphen aufzuteilen, wird mit dem Algorithmus aus Abschnitt 2.1 eine B 2 Partition des Graphen G gesucht. Bei der Partitionierung entstehen O(N/B 2 ) Teilgraphen und O(N/B) Seperatorknoten. In Abbildung 10 ist einmal links ein Beispielgraph zu sehen, welcher als Eingabe für den Algorithmus verwendet wird. Der Übersichtlichkeit halber haben alle Kanten eine Länge von 1. Weiterhin ist in der Abbildung eine gefundene Partition des Graphen in die Teilgraphen G 1,..., G k zu sehen. Für dieses Beispiel gilt B = 2. Ein Teilgraph G i darf also nicht gröÿer sein als 4 Knoten. Jeder dieser Teilgraphen G 2 s G 1 s G 3 3 s G 4 G 5 Abbildung 10: Aufteilen des Graphen hat bestimmte Knoten aus dem Seperator S, welcher die Partition des Graphen erzeugt, als Grenzknoten zu anderen Teilgraphen. Diese Grenzknoten werden mit den Knoten des entsprechenden Teilgraphen G i zu R i vereinigt. Die Teilgraphen R i werden nun unabhängig voneinander betrachtet. Innerhalb eines Teilgraphen wird nun der Kürzeste Weg von jedem, in R i hinzugefügtem, Seperatorknoten zu allen anderen Seperatorknoten berechnet. Weiterhin wird aus den Seperatorknoten ein Graph G R konstruiert. G R ist in Abbildung 10 rechts zu sehen. In G R gibt es eine Kante zwischen einem Paar von Seperatorknoten, wenn es ein R i gibt, welches die beiden

78 74 Knoten enthält. Das Gewicht dieser Kante ist die, innerhalb von R i berechnete, Entfernung. Bendet sich ein Paar von Seperatorknoten in mehreren Treilgraphen R i,..., R j so wird das Gewicht der Kante zwischen den Knoten auf das geringste Gewicht aus den Teilgraphen gesetzt. Als nächstes wird auf G R das kürzeste Wege Problem gelöst. Es wird also zu jedem Seperator Knoten die Entfernung zu s berechnet. Da G R aus den Sepertorknoten besteht, benden sich O(N/B) Knoten in G R. Das Problem läst sich auf G R generell nicht I/O ezient schnell lösen. Aber da die Anzahl der Knoten O(N/B) ist, ergibt sich für einen Algorithmus, welcher das kürzeste Wege Problem allgemein löst, eine Laufzeit von O(sort(N)). Die kürzesten Wege auf G R sind in Abbildung 11 links zu sehen s G G G 3 1 G 1 s 1 s G 2 G 3 G 4 G 4 G G 5 Abbildung 11: Übertragen der küzesten Wege auf die Teilgraphen Mit den Entfernungen der Seperatorknoten zu s können nun innerhalb der Teilgraphen G 1,..., G k die Entfernungen zu den einzelnen Knoten berechnet werden. In Abbildung 11 ist in der Mitte der Graph mit den Entfernungen der Seperaotrknoten zu sehen. Es wird jetzt innerhalb eines jeden Teigraphen G i der kürzeste Weg von einem Seperatorknoten u zu jedem Knoten v berechnet. Der letztendliche kürzeste Weg von s zu v ist dann der kleinste Wert für die Summe aus der Entfernung von s nach u und von u nach v. Die Werte für die küzesten Wege des Beispiels sind in Abbildung 11 rechts zu sehen Laufzeit des Algorithmus Für das Finden der Partition wird der Algorithmus aus Abschnitt 2.1 verwendet, es werden also O(sort(N) Schritte benötigt. Das Konstruieren der Teilgraphen benötigt O(scan(N)) I/O Operationen, da hier nur die einzelnen Teilgraphen G 1,..., G k nur einmal ausgelesen und mit den entsprechenden Seperatorknoten in den Speicher geschrieben werden müssen. Um den Graphen G R zu erstellen und die Entfernungen zwischen den Knoten zu berechen werden O(scan(N)) Schritte benötigt, da hier jeweils nur die Teilgraphen G 1,..., G k in den Speicher eingelesen werden müssen, um die kürzesten Wege innerhalb des Speichers zu nden. Um die kürzesten Wege in G R zu berechnen, werden wie oben schon erwähnt, O(sort(N)) Schritte benötigt. Für die kürzesten Wege innerhalb der Teilgraphen wird wieder jeder Teilgraph in den Speicher eingelesen, um die kürzesten Wege zu berechnen. Dies benötigt wieder O(scan(N)) Schritte. Da die langsamste Laufzeit der einzelnen Schritte O(sort(N)) ist, ergibt sich eine Gesamtlaufzeit

79 75 von O(sort(N)). 4 Tiefensuche Bei der Tiefensuche werden auch die Knoten eines Graphen durchlaufen. Allerdings wird hierbei nicht wie bei der Breitensuche alle Knoten in einer Entfernungsebene sofort besucht, sondern es wird zuerst versucht, in die Tiefe des Graphen zu gehen. In Abbildung 12 ist ein Beispiel für den Durchlauf der Tiefensuche zu sehen. Die Knoten wurden in s Abbildung 12: Beispiel für Tiefensuche der Reihenfolge ihrer Numerierung besucht. Hier wurde zunächst versucht eine Reihe von Knoten möglichst Tief in den Graphen hinein zu nden, bis ein Knoten keinen unbesuchten Nachbarknoten mehr besitzt. Dies ist im Beispiel bei Knoten 6 der Fall. Tritt dies auf, so wird von dem entsprechenden Vorgängerknoten aus ein weiterer unbesuchter Nachfolgeknoten gesucht. Dieser Ablauf wird so lange ausgeführt, bis alle Knoten des Graphen besucht wurden. 4.1 Planare Graphen Um die Tiefensuche auf einem planaren Graphen I/O ezient zu gestalten wir die Struktur eines solchen Graphen ausgenutzt. Eine besondere Eigenschaft eines planaren Graphen ist es, dass es keine sich überkreuzenden Kanten gibt. Jede Fläche des Graphen ist also nur auÿen durch Knoten und Kanten begrenzt. So ist es möglich die einzelnen Flächen den Graphen zu betrachten und diese in Ebenen zu ordnen. Der I/O eziente Tiefensuch-Algorithmus geht so vor, dass zuerst die Flächen des Eingabe-Graphen G in Ebenen um eine anliegende Fläche des Startknotens s angeordnet werden. Danach wird anhand dieser Ebenen ein Tiefensuch-Pfad durch den Graphen konstruiert. Um zuerst den Graphen in die Ebenen zu partitionieren wird der in Abschnitt 3.1 vorgestellte Algorithmus zur Suche von küzesten Wegen bzw. zur Breitensuche verwendet. Zuerst wird aber in dem Graphen eine Fläche als die zentrale Fläche festgelegt. Danach muss aus der Graph G zu einem sogenannten face-on-vertex Graphen erweitert werden. In Abbildung 13 ist einmal links ein Beispiel für einen Graphen zu sehen. Die Zentrale Fläche ist die jenige, welche sich rechts unterhalb von dem Knoten s bendet. In der

80 76 Mitte von Abbildung 13 ist der face-on-vertex Graph zu sehen. Hier wird einmal zu jeder Fläche des Graphen ein Knoten hinzugefügt(weiÿe Kästchen) und dieser Knoten wird mit jeden Knoten von G verbunden, welcher die entsprechende Fläche begrenzt. Auf diesem Graphen wird nun die Tiefensuche gestartet und zwar ausgehend von dem s Abbildung 13: Patitionieren der Flächen in Ebenen zu der zentralen Fläche hinzugefügten Knoten. Dieser ist in Abbildung 13 markiert. Mit dem Ergebnis der Tiefensuche lassen sich die die Flächen in die entsprechenden Ebenen einteilen. Hierbei ist die Ebene i einer Fläche die DFS-Entfernung j/2 des face-on-vertex Knotens. Aus dieser Partitionierung heraus werden Teilgraphen H 0,..., H k gebildet. Hierbei enthält ein Teilgraph H i alle Kanten aus der i-ten Ebene der Partition, welche mit keinem Knoten verbunden sind, welcher die Fläche einer tiefer liegenden Ebene begrenzt. In Ab- H 1 H 0 H E 1 2 s E 2 Abbildung 14: Ermitteln des Tiefensuch Durchlaufs bildung 14 sind links die Teilgraphen H 1, H 2, H 3 des Beispielgraphen zu sehen. Weiterhin werden Verbindungskanten E 1,..., E k festgelegt, wobie E i die Kanten sind, welche den Teilgraphen H i mit der nächst niedrigeren Ebene verbinden. Diese Verbindungskanten sind in Abbildung 14 in der Mitte zu sehen. Zuletzt wird nun der Tiefensuchbaum mit Hilfe der Teilgraphen H 0,..., H k und den Verbindungskanten E 1,..., E k gebildet. Hierzu wird in dem untersten Teilgraphen H 0 zuerst ausgehend von s ein ein Pfad um die Ebene 0 herum gelegt. Allerdings gehört die letzte Kante nicht mehr zu dem Pfad, da sie sonst einen Kreis schlieÿen würde. Dies ist in Abbildung 14 rechts zu sehen. Um nun auf die nächst höhere Ebene i+1 zu gelangen wird

81 77 aus den Verbindungskanten E i+1 der nächsten Ebene die Kante ausgewählt, welche den Knoten aus der Ebene i, welcher am tiefsten in dem bisherigen Baum liegt, mit einem Knoten aus dem Teilgraphen H i+1 verbindet. Danach werden dem Baum alle Kanten aus H i+1 hinzugefügt, auÿer die letzte, welche einen Kreis schlieÿen würde. Dies ist in Abbildung 14 rechts in der zweiten Ebene zu sehen. Ist H i+1 nicht zusammenhängend, so wird für jede zusammenhängende Komponente eine Kante aus den Verbindungskanten ausgewählt. Dies ist in Abbildung 14 rechts in der ditten Ebene zu sehen. Wurden diese Aktionen für jede Ebene durchgeführt, ergibt sich ein Tiefensuch-Baum für den Graphen G. Laufzeit Um den Graphen in die Ebenen zu partitionieren wurde einmal der face-onvertex Graph konstruiert, was O(sort(N)) I/O Operationen benötigt, da zuerst in G die Flächen gefunden werden und die entsprechenden Knoten ihnen zugeordnet werden müssen. Der Tiefensuch Durchlauf benötigt auch O(sort(N)) Schritte, wie in Abschnitt 3.1 beschrieben. Um den Tiefensuch-Baum zu nden muss zu jedem Übergang zwischen den Ebenen die Verbindungskante gefunden werden, wozu die Teilgraphen H i und H i+1 untersucht werden müssen. Es ergibt sich hierfür also eine Laufzeit von O(sort( H i + H i+1. Da insgesamt jedes H i nur zwei mal betrachtet wird, lässt sich die Gesamtlaufzeit für das Finden des Tiefensuch-Baumes auf O(sort(N)) abschätzen. Für die Gesamtlaufzeit ergibt sich also eine Laufzeit von O(sort(N)). Literatur [1] L. Toma, N. Zeh. I/O-Ecient Algorithms for Sparse Graphs. In Algorithms for Memory Hierarchies, pages [2] A. Maheshwari and N. Zeh. I/O-ecient algorithms for graphs of bounded treewidth. In Proc. 12th Ann. Symp. on Discrete Algorithms, pages ACMSIAM, [3] L. Aleksandrov and H. Djidjev. Linear algorithms for partitioning embedded graphs of bounded genus. SIAM Journal of Discrete Mathematics, pages , 1996.

82 78

83 Algorithmische Geometrie im externen Speicher 1 Einleitung Raphael Golombek Algorithmische Geometrie ist ein Bereich der Informatik, der sich mit der Entwicklung effizienter Algorithmen zur Lösung geometrischer Probleme und deren Analyse beschäftigt. Seit den Anfängen in den 70er Jahren entwickelte sich dieser Bereich zu einer übergreifenden Disziplin, die in Bereiche wie Komplexitätstheorie oder Algorithmenentwurf Einzug erhielt. Das Einsatzfeld für geometrische Algorithmen ist vielfältig, und viele Problemstellungen basieren auf realen Anwendungsbereichen. In der Computergrafik werden räumliche Datenstrukturen im Zusammenspiel mit Verdeckungsalgorithmen eingesetzt. Die so mögliche frühe Datenreduktion beschleunigt die Berechnung der Einzelnen Bilder einer Szene. Im Bereich der Datenbanken werden große Datensätze über ihre Attribute als Punkte in einem mehrdimensionalen Raum interpretiert. Mit Hilfe von geometrischen Algorithmen können hier Bereichsanfragen effizient beantwortet werden. Durch den rasanten technischen Fortschritt wuchs die Speicherkapazität der Computer und somit auch die Einsatzmöglichkeiten. Hochauflösende Bilder nun in hoher Anzahl gespeichert werden. Die NASA z.b. stellt mit ihrem Programm World Wind insgesamt 4.6 Terabyte Bildmaterial, unserer Erdoberfläche in verschiedenen Auflösungen, zur Verfügung. Daten mit diesen Ausmaß können zur Verarbeitungszeit nicht mehr komplett im internen Speicher gehalten werden und müssen auf, den deutlich langsameren, externen Speicher ausgelagert werden. In solch einem Fall wird die Laufzeit eines Algorithmus nicht mehr durch die Anzahl seiner Rechenschritte dominiert, sondern durch die Anzahl der Datenzugriffe(im weiteren I/O s) auf den Externen Speicher. Diese Erkenntnis führte Anfang der 90er Jahre zur Intensivierung der Forschung im Bereich der geometrischen Algorithmen im externen Speicher. In dieser Seminararbeit werden Algorithmen zur Lösung geometrischer Probleme im externen Speicher vorgestellt. Aufgrund der Komplexität der Algorithmen und den Einschränkungen bezüglich der Ausmaße dieser Ausarbeitung, werden nur drei der 22 Problemstellungen in aller Ausführlichkeit beschrieben Tabelle 1 auf der nächsten Seite zu allen Anderen wird eine kurze Zusammenfassung der Problemstellung und Lösung vorgestellt. 2 Das Externe Speichermodell Bevor die einzelnen Problemstellungen genauer betrachtet werden, wird in diesem Kapitel das, der Analyse zu Grunde liegende Speichermodell [3] vorgestellt. Eine grundlegende Eigenschaft des hier betrachteten externen Speichers ist die extrem lange Zugriffszeit

84 No. Name des Problems No. Name des Problems 1 Convex Hull 12 Segmentstabbing 2 Halfspace Intersection 13 Segment Sorting 3 Closest Pair 14 Endpoint Dominance 4 K-Bichromatic Closest Pairs 15 Trapezoidal Decomposition 5 Nearest Neighbor 16 Polygon Triangulation 6 All Nearest Neighbors 17 Vertical Ray-Shooting 7 Reverse Nearest Neighbors 18 Planar Point Location 8 K-Nearest Neighbors 19 Bichromatic Segment Intersection 9 Halfspace Range Searching 20 Segment Intersection 10 Orthogonal Range Searching 21 Rectangle Intersection 11 Voronoi Diagram 22 Polygon Intersection Tabelle 1: Markierte Probleme werden detaillierter erklärt in Relation zu der Zugriffszeit auf den internen Speicher. Es basiert darauf, dass Daten zwischen den verschiedenen Speicherschichten blockweise übertragen werden, dabei ist im Rahmen dieser Seminararbeit die Betrachtung des Hauptspeichers und externer Massenspeicher (Festplatten) ausreichend. Das Modell besitzt folgenden Parameter: N = Anzahl Elemente im Problembereich M = Anzahl Elemente, die gleichzeitig in den Hauptspeicher passen B = Anzahl Elemente, die in einen Block passen D = Anzahl unabhängiger Festplatten P = Anzahl parallel arbeitender Prozessoren Q = Anzahl gleichzeitiger Anfragen Z = Anzahl Elemente im Antwortset Die Parameter D und P sind bei den hier vorgestellten Problemen immer mit 1 vorbelegt. Die letzten zwei Parameter sind Zusätze für Algorithmen, die mehrere gleichzeitige Anfragen erlauben. Da bei einem I/O-Zugriff stets ein Block gelesen wird, führen wir weitere Variablen ein um die Laufzeitanalyse lesbarer zu gestalten. n = N B, k = K B, t = T B, m = M B. Abbildung 1 auf der nächsten Seite verdeutlicht nochmals den Modellaufbau. Das Modell erlaubt nur Operationen auf Elementen im Hauptspeicher, falls weitere Elemente zur Berechnung gebraucht werden, muss eine I/O-Operation durchgeführt werden. Die Leistung eines externen Algorithmus wird hier anhand der I/O-Zugriffe und dem Speicherverbrauch während seiner Laufzeit gemessen. Im Folgenden werden einige oft verwendete Techniken zum Lösen geometrischer Probleme vorgestellt. Diese können mit kleinen Anpassungen auch für geometrische Probleme im externen Speicher genutzt werden. Als erstes wird das Konzept der Reduktion vorgestellt, ein Beispiel einer Reduktion mittels geometrischer Dualität ist im Kapitel 5 auf

85 CPU Fast Memory M B Large Memory Abbildung 1: Externes Speichermodell Seite 13 gegeben. Eine weitere, oft angewendete Technik, ist das Plane Sweeping. Für den externen Speicher angepasst, wird es als Distribution Sweeping bezeichnet. 2.1 Reduktion Die Reduktion ist eine gängige Methode um Algorithmen für Probleme, aus bereits gelösten Problemstellungen, abzuleiten. Das Vorgehen dabei sieht wie folgt aus. Gegeben seien zwei Probleme A und B. Für das Problem B ist die Lösung bekannt, weiterhin kennen wir die Laufzeit O(f B (N)) eines Algorithmus, der das Problem B löst. Wir wollen nun das Problem A mit Hilfe von B lösen und gehen dazu in zwei Schritten vor: 1. Zuerst transformiere die Eingabe des Problems A in eine Eingabe vom Typ B. 2. Früher den Algorithmus für das Problem B mit der transformierten Eingabe aus. 3. Transformiere die Ausgabe vom Typ in eine Ausgabe vom Typ A und gebe sie Aus. Dabei gilt die Bedingung, dass die Transformationen zwischen den Eingaben bzw. Ausgaben der zwei Typen nicht mehr Zeit in Anspruch nehmen, als der Algorithmus für das Problem B zur Berechung des Ergebnisses benötigt. 2.2 Distribution Sweeping Viele effiziente, interne Algorithmen basieren auf der plane sweeping [9] Technik. Die Grundidee ist es ein statisches d+1-dimensionales Problem in endlich viele dynamisch konfigurierte d-dimensionale Probleme aufzuteilen und diese zu Lösen. Dabei lassen sich damit die 2-Dimensionalen Problemstellungen am effektivsten lösen, daher wird im Folgenden auch nur dieser Fall betrachtet. Bei der plane Sweeping Technik wird eine gedachte Linie, senkrecht zu der x-achse, über die Datenmenge geführt, jedes Element das zum Zeitpunkt t von der gedachten Linie geschnitten wird, gilt als aktiv zum Zeitpunkt t. Nur aktive Objekte werden in den Berechnungen einbezogen. Das Problem muss dabei so modelliert werden, dass die aktiven Elemente ausreichen um eine gültige Lösung zu ermitteln. Alle aktiven Elemente werden in einer sweep-line Struktur verwaltet, die bei jedem Ereignis aktualisiert wird.

86 Diese Datenstruktur kann in logarithmischer Zeit verwaltet werden, wenn die Elmente des Problems sortierbar sind z.b. bezüglich der y-achsenwerte. Ereignisse sind Zustandswechsel bezüglich des Schnittes eines Elements mit der gedachten Linie und werden in einer Prioritätswarteschlange verwaltet. Auch die Warteschlange kann in logarithmischer Zeit verwaltet werden, falls die Liste der aktiven Elemente sortierbar ist. Aufgrund dieser Eigenschaften sind viele Algorithmen aufbauend auf der plane sweeping Technik optimal. Eine direkte Umsetzung dieser Technik im externen Speicher würde bei Algorithmen mit einer Laufzeit von O(N log 2 N) zu O(N log B N) vielen externen Speicherzugriffen führen. Bei Problemen mit einer externen unteren Schranke von Ω(n log m n) könnte man damit also keine optimale Lösung finden. Die externe Variante wird verbessert indem man den Algorithmus rekursiv anwendet und den Problemraum in Θ(m) viele vertikale Streifen unterteilt. In jeder Rekursionstiefe werden nur Elemente verarbeitet, die mit Elementen anderer Streifen interagieren. Interaktionen innerhalb eines Streifens werden in späteren Rekursionsschritten behandelt. Die Rekursion bricht ab, wenn das Subproblem komplett in den Hauptspeicher passt. Durch die Unterteilung kann pro Streifen immer ein Datenblock im Hauptspeicher gehalten werden. Da die Steifen Senkrecht sind, wird auch die sweeping line Senkrecht geführt. Falls nun eine Rekursionstiefe mit einer linearen Anzahl von I/Os auskommt, ist die gesamte Laufzeit des Distribution Sweeping O((n)log m (n)). Diese Aufteilungstechnik wurde ursprünglich in dem distribution sort [17] Algorithmus verwendet, daher wird diese externe Variante des plane sweeping Algorithmus auch distribution sweeping genannt. Abbildung 2 soll das distribution sweeping graphisch verdeutlichen. M/B viele Streifen in jeder Rekursionstiefe Abbildung 2: Distribution sweeping

87 3 Endpoint Dominance Problem Problemdefinition: Sei S eine Menge von N sich nicht überschneidender Segmente in der Ebene. Finde für jeden Endpunkt eines Segments in S das dominante Segment (falls existent). Als dominantes Segment bezüglich eines Punktes p wird das direkt darüber liegende Segment bezeichnet. Abbildung 3 zeigt die dominanten Elemente einiger Segmente. Grundidee der Lösung: Abbildung 3: Endpunkte und ihre dominanten Segmente 1. Baue einen Segmentbaum auf, der die Menge S der Segmente anfragefreundlich abspeichert. 2. Durchlaufe den Baum gleichzeitig für alle Segmentendpunkte, berechne das dominante Segment für jeden Anfragepunkt und gebe es aus. Bei dem Endpoint Dominance Problem (EDP) wird zur Datenverwaltung einer, an die Problemstellung angepasster externer Segmentbaum verwendet. Um viele Anfragen gleichzeitig und effizient zu beantworten werden die Segmente im Baum ähnlich der Technik der fraktionalen Kaskadierung [5] vorverarbeitet. 3.1 Externer Segmentbaum: Ein Segmentbaum ist eine dynamische Datenstruktur zum Abspeichern einer Menge von Segmenten bezüglich einer Dimension. Er ist so aufgebaut, dass bei gegebenem Anfragepunkt alle Segmente, die diesen enthalten, effizient ausgegeben werden können. Der Segmentbaum ist perfekt balanciert, alle inneren Knoten haben einen Ausgangsgrad von (m/4). Die Blätter enthalten jeweils M/2 viele aufeinander folgende Segmentendpunkte, die Tiefe des Baumes beträgt O(log m n). Abbildung 4 auf der nächsten Seite zeigt

88 einen Segmentbaum. slab_1 slab_2 slab_3 slab_4 slab_5 multislab_1-5 multislab_2-4 multislab_3-4 O(logmN/B) Abbildung 4: Segmentbaum mit Slabs und Multislabs Slabs und Multislabs: Jeder Knoten im Baum ist mit einem Bereich des Problemraumes assoziiert in dem alle Segmentendpunkte liegen, die im Baum unter diesem Knoten eingeordnet sind. Wir bezeichnen solch einen Bereich als Slab, in der Abbildung 4 sind Slabs durch gepunktete Linien dargestellt. Zusammenhängende Slabs werden als Multislabs bezeichnet, die Anzahl der Multislabs pro Knoten beläuft sich auf m/8 (m/4). Eine wichtige Beobachtung ist hier, dass die Anzahl der Multislabs quadratisch von dem Verzweigungsgrad des Baumes abhängt. Dies ist auch der Grund den Verzweigungsgrad als (m/4) zu wählen, da dadurch die Anzahl der Multislabs pro Knoten in O(m) liegt und somit mindestens ein Block pro Multislabliste gleichzeitig im Hauptspeicher gehalten werden kann Klassifizierung der Segmente Beim Aufbau des Segmentbaums unterscheiden wir zwei Typen von Segmenten, lange und kurze Segmente. Diese Unterscheidung wird im Algorithmus verwendet um die Segmente in der richtigen Baumtiefe abzulegen. Lange Segmente sind Segmente, die ein oder mehrere Slabs überlappen, eine Kopie eines solchen Segment wird in der Multislabliste, des größten Multislabs das es überlappt, gespeichert. Kurze Segmente sind alle Segmente, die nicht als Lange Segmente bezeichnet werden. Diese Segmente werden tiefer in den Baum geschickt, wo sie vielleicht als lang klassifiziert werden und abgespeichert werden. Die Teile langer Segmente, die ein Slab nur teilweise überlappen, also überstehende Bereiche, werden kopiert und als kurze Segmente behandelt. Da maximal 2 solcher Teile pro Segment und pro Baumtiefe anfallen, ergibt sich eine maximale Anzahl abzuspeichernder

89 Segmente von O(n log m n) Aufbau eines Externen Segmentbaumes Die Baumstruktur wird nun mit Segmenten gefüllt, dabei sollen die Segmente, wie in auf der vorherigen Seite beschrieben im Baum abgelegt werden. Zusätzlich wird verlangt, dass die Segmente in den Multislablisten, in denen sie referenziert sind, bezüglich ihrer y-koordinate sortiert sind. Da wird beim EDP nur mit sich nicht schneidenden Segmenten arbeiten, ergibt sich eine solche eindeutige Sortierung indem man Segmente bezüglich der y-werte an einer der Grenzen des Multislabs sortiert. Diese Sortierung wird im weiteren Teilen des EDP-Algorithmus immer wieder verlangt um effizient zu arbeiten, dazu muss jedoch noch gezeigt werden, dass die bereits sortierten Multislablisten eines Knotens auf effizientem Wege zu einer sortierten Multislabliste gemischt werden können. Lemma 1: Die N Segmente, die in den Multislablisten eines internen Knotens in einem Externen Segmentbaum gespeichert sind, können mit O(n) vielen I/O-Operationen sortiert werden. Beweis: Wir behaupten, dass man die Sortierte Liste aller Segmente aufbauen kann, indem man wiederholt die obersten Elemente, der bereits sortierten Multistablisten, betrachtet und stets das Höchste, bezüglich der y-koordinate, als nächstes auswählt. Wir nehmen widersprüchlich an, es gibt ein oberstes Segment s in einer der Multistablisten welches über allen anderen oberstes Segmenten der anderen Multistablisten liegt, jedoch unter einem noch nicht verarbeiteten Segment t liegt. So existiert eine Reihe von Segmenten s 1, s 2,..., s i für die gilt t liegt über s 1, s 1 über s 2 und s i über s. Aber wenn s i über s liegt, wird die Bedingung, dass s über allen anderen obersten Segmenten liegt, verletzt. Da es O(m) viele Multislablisten gibt, kann pro Liste ein Block im Speicher gehalten werden. Somit lässt sich die sortierte Liste mit O(n) vielen I/O-Operationen durch einem standard externen Sortieralgorithmus aus O(m) vielen Multislab Listen erstellen. Um nun den externen Segmentbaum aus N Segmenten aufzubauen wird als erstes eine, bezüglich der x-achse, sortierte Liste aller Segmentendpunkte berechnet. Dies geschieht mit einem standard Sortieralgorithmus mit O(n log m n) externen I/Os. Diese Liste wird im Algorithmus benutzt um die Intervallgrenzen für die (m/4) vielen Slabs eines Knotens zu berechnen. Danach werden die O(m) vielen Multislablisten eines Knotens wie folgt erstellt. Durch einen Scan werden die langen Segmente auf die entsprechenden Multislablisten verteilt. Danach werden alle Multislablisten bezüglich der y-koordinate sortiert. Bis auf die Wurzel haben alle Knoten einen Elternknoten, von diesem erhält jeder zwei sortierte Listen von Segmenten. Diese Listen werden auf die sortierten Multistablisten verteilt. Die Kinder des aktuellen Knotens erwarten auch zwei sortierte Listen als Eingabe. Die erste Liste enthält alle langen Segmente, die einen Endpunkt in dem Slab des jeweiligen Kindknotens hat und den anderen Endpunkt links von diesem Slab. Die zweite

90 Liste wird genauso aufgebaut, diesmal ist jedoch der zweite Endpunkt rechts vom Slab. Da beide Listen aus den bereits sortieren Multislablisten Konstruiert werden, sind sie bezüglich des y-wertes sortiert. Abbildung 5 verdeutlicht die Aufteilung der Segmente. Elternknoten berechnet 2 Listen pro Kindknoten v(k) k linke Segmentliste von Kind k Slab von k rechte Segmentliste von Kind k Abbildung 5: Endpunkte und ihre dominanten Segmente Das einmalige Sortieren der Endpunkte kostet O(n log m n) I/Os. Die Segmente in einer Tiefer können in O(n) I/Os auf die Multistablisten verteilt werden, insgesamt kostest das in allen Tiefen zusammen O(n log m n) I/Os. Das Sortieren der Segmente in den Multistablisten eines Segmentbaums der Tiefe O(log m n) kostet zusammen O(n log m n) I/Os, dies ist nur möglich durch die Wiederverwendung der Vorsortierung mittels der zwei Listen, dadurch wird jedes Segment nur einmal in irgendeiner Tiefe sortiert. Würde man den Segmentbaum ohne diese Listen aufbauen, müssten in jeder Rekursionstiefe O(n log m n) I/O-Operationen ausgeführt werden. Die Verwendung der zwei Listen verbessert die Laufzeit des Aufbaus von O(n*log 2 mn) auf O(n log m n) I/Os Anfragen im Externen Segmentbaum Der im vorherigen Kapitel aufgebaute externe Segmentbaum kann nun verwendet werden um die 2N vielen dominanten Segmente zu berechnen. Der folgende einfache Algorithmus findet das dominante Segment für einen Anfragepunkt p: 1. Untersuche jeden Knoten von der Wurzel aus auf dem Weg zu den Blatt, dass die x-koordinate des Punktes p enthält. 2. An jedem Knoten, suche in allen Multislablisten, die p enthalten, nach dem lokal dominanten Segment und vergleiche es mit dem bisher global dominantesten Segment.

91 3. Bei dem Blatt das p enthält angekommen gebe das global dominante Segment aus. Um alle 2N Anfragepunkte gleichzeitig zu verarbeiten, kann eine, dem batch filtering ähnliche, Technik [8] verwendet werden. Dabei werden alle Anfragepunkte erst in einer Baumtiefe verarbeitet, bevor sie in die Nächste verschoben werden. Bei diesem Vorgehen wird die zeitliche Lokalität ausgenutzt um die Anzahl der I/Os zu reduzieren. Trotz der erwähnten Optimierung erzielt dieser Algorithmus nicht die gewünschte Laufzeit von O(n log m n). Maßgebend ist hier, dass pro Anfragepunkt O(m) viele Multislablisten eines Knotens geprüft werden müssen und jede dieser Listen O(n) viele Elemente enthält. Bei O(n) Anfragepunkten und einer Segmentbaumtiefe von O(log m n) braucht der Algorithmus ungünstige O(n 2 log m n) I/O-Operationen. Gesucht wird ein Algorithmus der mit O(n log m n) I/O-Operationen auskommt, dazu dürfen in jeder Tiefe des Segmentbaumes nur linear viele Operationen, für alle Anfragepunkte zusammen, durchgeführt werden. Der hier gemachte Ansatz basiert auf einer der fraktionalen Kaskadierung [4, 5] ähnlichen Technik als Vorverarbeitungsschritt und einem gleichzeitigen propagieren aller Anfragepunkte durch den Segmentbaum von den Blättern aus. Im Vorverarbeitungsschritt werden einige Segmente aus den Elternknoten in die Kinderknoten verteilt, startend mit der Wurzel wird für jedes der (m/4) vielen slabs eine Sampleliste erstellt. In die Samplelisten legen wir jedes 2 (m/4)- te Segment rein, das das entsprechende Slab überlappt und schneiden dies auf die Größe des Slabs zu. Die so verteilten Segmente werden im Folgenden Samples genannt. Die relevanten Segmente werden durch Scannen der sortierten Liste aller Segmente des aktuellen Knotens ermittelt, nach Lemma auf Seite 7 gelingt das in O(n). Zusätzlich werden (m/4) viele Zähler verwaltet um die Segmente auszuwählen. Die Samples eines Slabs werden in seine Multislabliste, die die Segmente enthält die das ganze Slab überlappen, eingefügt. Die Samples werden auch an die Blätter verteilt. Dadurch werden Informationen über Segmente des Elternknotens an die Kinder weitergegeben. Im Späterem Verlauf werden wir sehen wie dies ausgenutzt werden kann, um nur noch potentiell dominante Segmente bezüglich eines Anfragepunktes zu betrachten. Zunächst muss ein Lemma bezüglich der Laufzeit, des Speicherverbrauchs und der Ausgabe der Vorverarbeitung bewiesen werden. Lemma 2: Die beschriebene Vorverarbeitung kann in O(n log m n) I/O-Operationen durchgeführt werden. Danach sind immer noch O(N) Segmente in jeder Schicht des Segmentbaums gespeichert. Die Blätter enthalten weniger als M viele Segmente. Beweis: Bevor irgendwelche Samples im Baum nach unten gereicht werden, sind in jeder Tiefe maximal 2N viel Segmente. Sei N i die Anzahl langer Segmente, Originale und Samples, aller Knoten in einer Tiefe i nach der Vorverarbeitung. Für die Wurzel gilt N 0 2N. Es werden maximal N i /(2 m/4) (m/4) = N i /2 viele Samples in die (i+1)-te Tiefe geschickt. Es gilt also N i+1 2N + N i /2. Durch Induktion können wir zeigen dass für alle i gilt, N i = (4 (1/2)i 1) N = O(N). Nach Lemma auf Seite 7 und weil es O(m) viele Multislablisten gibt folgt, dass wir auf jedem Segment konstant viele I/O-Operationen durchführen. Die Anzahl von

92 Operationen in einer Tiefe i ist dann O(n i ) mit n i = N i /B. Somit folgt, dass die Vorverarbeitung O(n log m n) viele I/O-Operationen benötigt. Vor der Vorverarbeitung ist die Anzahl der Segmente in einem Knoten einer Baumtiefe i kleiner als die Summe aller Endpunkte die in den Blättern unter diesem Knoten gespeichert sind d.h. es sind weniger als M/2 (m/4) i. Betrachten wir die Anzahl der Segmente die ein Blatt in der Tiefe l nach der Vorverarbeitung besitzt. Da wir jedes 2 (m/4)-te Segment samplen, sind weniger als M/2 + Nl 1 /2 (m/4) Segmente in einem Blatt der Tiefe l vorhanden, wobei N l 1 die maximale Anzahl von Segmenten eines Knotens der Tiefe l 1 ist. Daraus folgt, N l M/2 + M/4 + N l 2 2 (m/4) 2. Lösen wir die Reihe bis zur Wurzel auf, ergibt sich N l < M. Nach der Vorverarbeitung sollen nun die 2N vielen Anfragen durch den Segmentbaum propagiert werden. Beim fraktionalen Kaskadierung werden die Elternknoten mit Informationen aus den Kinderknoten angereichert und der Baum von der Wurzel an durchlaufen. Hier jedoch haben wir die Informationen von Oben nach Unten geschickt, so müssen wir die Anfragen von den Blättern aus bis hin zur Wurzel propagieren. Im ersten Schritt werden Alle 2N Anfragepunkte bezüglich ihrer x-koordinate mit O(n log m n) I/Os sortiert und mit einem Scan in O(n) I/Os den entsprechenden Blättern zugeordnet. Dies ergibt für jedes Blatt eine unsortierte Liste von Anfragepunkten. Als nächstes wird über alle Blätter iteriert und in jedem Blatt die lokal dominanten Segmente, der dort enthaltenen Anfragepunkte, berechnet. Alle Segmente eines Blattes Passen laut Lemma auf der vorherigen Seite auf einmal in den Hauptspeicher, daher können wir sie alle dort einladen und einen internen Algorithmus zum Suchen der lokal dominanten Segmente verwenden. Da es in den Blättern O(N) viele Segmente gibt, kann dies in O(2n + n) I/O-Operationen geschehen. Nun werden die Anfragepunkte in den Blättern bezüglich ihrer lokal dominanten Segmente in O(n log m n) sortiert. Die Daten sollen von den Kindern an die Eltern weitergereicht werden, dazu muss ein Kindknoten eine einzelne Liste an den Vaterknoten weitergeben. Dieser Schritt wird als Filtern bezeichnet. Beim Filtern werden die (m/4) (Verzweigungsgrad des Segmentbaums) vielen sortierten Listen von Anfragepunkten zu einer sortieren Liste zusammengefügt. Weiterhin werden wieder (m/4) Listen benötigt, diesmal enthalten sie aber Segmente, von denen das jeweilige Slab überspannt wird. Überspannt z.b. ein Segment 4 slabs, so taucht er in ihren Listen auf. Wir Scannen nun die Liste aller Segmente und verteilen die überspannenden Segmente, falls wir auf ein Segment stoßen welches in den Slab s als Sample vorkommt, halten wir an und verarbeiten alle Anfragepunkte zwischen diesem Segment und dem davor in s gesampelten Segment. Diese Anfragepunkte kommen in der sortieren Anfrageliste des Slabs s hintereinander vor. Danach wird die Segmentliste, die s überspannt, geleert. Wichtig ist hierbei, dass wir zur keiner Zeit mehr als 2 (m/4) Segmente pro Slab im Speicher halten, bei (m/4) vielen Slabs, reicht

93 unser interner Speicher also aus um den Scan, ohne die Verarbeitung der Anfragepunkte zwischen zwei Samples, in linearer Anzahl von I/Os durchzuführen. Um die Anfragepunkte zwischen zwei Samples zu verarbeiten, reservieren wir 2 (m/4) Blöcke Speicher, für jedes Segment zwischen den zwei Samples jeweils einen Block. Die Blöcke dienen zum Abspeichern der Anfragepunkte, für die das entsprechende Segment ein lokal dominantes Segment ist. Die Anfragen werden aus der sortierten Liste betrachteten Slabs ausgelesen und auf die Blöcke verteilt, danach werden die Listen konkateniert und an die Ausgabeliste des Slabs angehängt. Ist der Scan der Multislabliste durchgelaufen, mischen wir die Ausgabelisten der Slabs zu einer Eingabeliste für den Elternknoten. Das Mischen der O(m) sortieren Listen geschieht nach Lemma auf Seite 7 in O(n). In der Wurzel angekommen, die dominanten Segmente aller 2N Anfragepunkte ermittelt. Fassen wir die verbrauchten Ressourcen noch mal zusammen: Teilaufgaben Speicher Anz. I/Os Aufbau des Segmentbaums Segmente vorsortieren bezüglich x-achse O(n log m n) O(n log m n) Verteilen der Segmente auf Multislabs O(n log m n) O(n log m n) Multislablisten in O(log m n) Ebenen sortieren O(n log m n) O(n log m n) Vorverarbeitung des Segmentbaums Erstellen und Verteilen der Samples O(n log m n) O(n log m n) 2n Anfragen im Segmentbaum Anfragen bez. x-koordinate sortieren 1 O(n log m n) O(n log m n) Zu den Blättern zuordnen 1 O(n) O(n) Lokal dominante Segmente ermitteln 1 O(n) O(n) Anfragen bez. der lokal dominanten Segmente sortieren 1 O(n log m n) O(n log m n) Filterschritte O(n log m n) O(n log m n) Gesamt O(n log m n) O(n log m n) Der hier vorgestellte Algorithmus löst das Endpoint Dominance Problem in O(n log m n) I/Os und braucht dazu zu keinem Zeitpunkt mehr als O(n log m n) Speicherblöcke. 4 Trapezoidal Decomposition Problem Das Trapezoidal Decomposition Problem ist stark mit dem Endpoint Dominance Problem verwandt, trotzdem ist es als eigenständige Problemstellung von Interesse. Es wird oft als ein Vorverarbeitungsschritt benutzt z.b. beim Lösen des Planar Point Location Problem [11, 15] oder bei einem Algorithmus zur Polygon Triangulation. Problemdefinition: Gegeben sei eine Menge N sich nicht schneidernder Segmente in 1 Einmalig zu Beginn des Anfragealgorithmus

94 der Ebene, berechne die Ebenenzerlegung, die durch das Erweitern von Linien in ± y- Richtung durch die Endpunkte des Segmente entsteht und solange fortgesetzt wird bis sie das direkt drüber bzw. drunter liegende Segment trifft (falls existent). Abbildung 6 verdeutlicht die Partitionierung. Abbildung 6: Partitionierung des Raumes beim Trapezoidal Decomposition Problem Das Trapezoidal Decomposition Problem ist ein Beispiel für die Anwendung der Reduktionstechnik. Mit Hilfe des EDP-Algorithmus wird eine Ebene mit Segmenten bezüglich der Segmentendpunkte Partitioniert. Grundidee der Lösung: 1. Berechne EDP auf der Menge der Segmente 2. Invertiere die Y-Koordinatenwerte der Segmente und berechne eine zweite Instanz des EDP 3. Setze die Ergebnisse der zwei EDP Lösungen zu einer Trapezoidal Decomposition zusammen Die ersten beiden Schritte sind einfach durchzuführen und kosten zusammen O(n log m n) I/Os. Im dritten Schritt berechnen wir den Verlauf der O(N) vielen vertikalen Trapezoidkanten. Die Endpunkte einer vertikalen Trapezkante lassen sich, während eines Scans der zwei Ausgaben, als Schnittpunkte zwischen der vertikalen Geraden durch einen Endpunkt mit den zwei dominanten Segmenten dieses Endpunktes in O(n) I/Os berechnen. Ein anschließendes Sortieren der Trapezkanten in O(n log m n) I/Os bringt diese in die richtige Reihenfolge für die Zerlegung der Ebene. Mit einem weiteren Scan lassen sich die Kanten nacheinander ausgeben.

95 5 K-Nearest Neighbors Problem Auch das Problem der K-nächsten Nachbarn wird mit Hilfe einer Reduktion gelöst. Genauer gesagt ist dies hier ein Beispiel für die Anwendung des Dualitätsprinzips. Die Lösung basiert auf einen Algorithmus zur Berechnung der k-niedrigsten Ebenen entlang einer vertikalen Linie indem zusätzliche Datenstrukturen benutzt werden um den Raum geschickt in Cluster einzuteilen. Problemdefinition: Gegeben sei eine Menge S von N punkten im R 2 und eine Zahl K mit 1 K N, sowie ein Anfragepunkt p in R 2. Gebe alle K Punkte aus S aus, die p am nächsten sind. Abbildung 8 auf der nächsten Seite zeigt den Fall für K = 4. Die Definition ist nor- K-Nearest Neighbors mit K=4 Abbildung 7: KNN Problem, Rot eingezeichnete der Anfragepunkt p malerweise nicht auf R 2 beschränkt, jedoch wird hier nur ein Algorithmus für diesen Fall vorgestellt. Grundidee der Lösung: 1. Mittels einer Dualität überführe das Problem in R 3, indem jeder Punkt p einer Ebene z in R 3 zugeordnet wird. 2. Berechne bezüglich des Anfragepunktes q die K niedrigsten Ebenen in R 3. Die zu diesen Ebenen zugehörigen Punkte im R 2 sind die K-nächsten Nachbarn von p. Das Problem der K-nächsten Nachbarn wird hier auf ein anderes Problem, das finden der K-niedrigsten Ebenen bezüglich eines Anfragepunktes q, reduziert. Die Reduktion geschieht anhand der Dualität p = (a 1, a 2 ) z = a a2 2 2a 1x a 2 y. Die Ebene z ist tangential zu einer Parabel im Punkt (a 1, a 2, a 2 1 a2 2 ). Abbildung 8 auf der nächsten Seite zeigt die Dualität anhand eines eindimensionalen Falles. Das Berechnen der dualen

96 a b o c 2-tiefsten Ebenen bezüglich der Geraden durch (ox,0) Abbildung 8: Dualität im eindimensionalem Fall Ebenen zu den N Punkte kann während eines Scans in O(n) I/Os gemacht werden. Können nun die K-niedrigsten Ebenen bezüglich eines Anfragepunktes q in O(n log m n) I/Os und verbraucht der Algorithmus nicht mehr als O(n log m n) viel Speicher, dann kann auch das KNN Problem mit diesem Ressourcenverbrauch gelöst werden. 5.1 K-lowest Planes Problem Wir untersuchen nun einen Algorithmus für das K-lowest Planes Problem. Problemdefinition: Sei H eine Menge von N Ebenen im R 3. Für jeder vertikale Linie l und jedes k sind die k niedrigsten Ebenen in H entlang l die Ebenen, deren Schnitte mit l die k kleinsten z-koordinatenwerte haben. Grundidee der Lösung: 1. Erstelle eine Datenstruktur um die N Ebenen aus H abzuspeichern so, dass Anfragen effizient beantwortet werden können. 2. Berechne bezüglich des Anfragepunktes q die K niedrigsten Ebenen in R 3. Die zu diesen Ebenen zugehörigen Punkte im R 2 sind die K-nächsten Nachbarn von p. 5.2 Geschichtete Datenstruktur Um eine effiziente Datenstruktur aufzubauen, bedienen wir uns der Technik des buttomup sampling von Mulmuley [14]. Es wird eine zufällige Permutation h1,..., h N der Ebenen in H gewählt. Sei β = Blog B n, für alle i [1, (log 2 (N/β) ] definierten wir

97 R i = {h 1, h 2,..., h 2 i}. Es entsteht dadurch eine Hierarchie zufällig gewählter Teilmengen R 0 R 1... R log2 (N/β H, der Größe 2 i. Die Datenstruktur besteht nun aus O(log 2 n) vielen Schichten, jeweils eine pro R i. Im weiteren schrieben wird r i = 2 i /B und meinen damit die Anzahl Blöcke die R i braucht. Die i-te Schicht bauen wir wie folgt zusammen. Als erstes bauen wir für R i das lower envelope in O(r i log B r i ) I/Os mit dem externen "3D-halfspace intersectionälgorithmus von Crauser [6]. Das lower envelope ist das 0-te Level des Arrangements [1] der Ebenen in R i. Der Algorithmus liefert das 0-te Level bereits trianguliert zurück, die Menge der Dreiecke von R i nennen wir R i. R i enthält O(2 i ) viele Dreiecke und kann somit in O(r i ) vielen Blöcken gespeichert werden. Als nächstes wird eine Datenstruktur zur Punktlokation aufgebaut (point-location Struktur). Diese Wird aus der orthogonalen Projektion von R i auf die x/y-ebene gewonnen. Dadurch ist es möglich ein Dreieck aus R i über oder unter einem Anfragepunkt p in O(log B n) I/Os zu ermitteln. Diese Struktur kann in O(r i log B r i ) I/Os aufgebaut werden und verbraucht O(r i ) Blöcke Speicher [8, 2]. Zuletzt bauen wir für jedes Dreieck eine Konfliktliste K( ). Ein Konflikt zwischen einem Dreieck und einer Ebene aus H entsteht dann, wenn die Ebene unter einem Punkt des Dreiecks liegt. Wir speichern jede Konfliktliste in hintereinander liegenden Blöcken so, dass wir sie in K( ) /B I/Os scannen können. Der halspace intersection Algorithmus von Crauser et al. [6] kann genutzt werden die Konfliktlisten der R i in O(nlog B r i ) zu berechnen. Das folgende Lemma ist elementar für die wahrscheinlichkeitstheoretische Betrachtung der Laufzeit des K-lowest Planes Algorithmus. Lemma 3: Sei 1 r N und R eine zufällige Menge der Ebenen aus H der Größe r. 1. E[ (R) K( ) ] = O(N) 2. Für jede vertikale Linie l, ist die erwartete Größe von K( ), mit (R) wobei die Linie schneidet, O(N/r). Aus dem ersten Teil des Lemmas resultiert, dass die erwartete Anzahl benötigter Blöcke um alle Konfliktlisten für eine Teilmenge R i abzuspeichern O(n) ist. Da diese den Speicherverbrauch von R i dominitert, ist die erwartete Anzahl von Blöcken um eine Sicht unserer Struktur zu verwalten O(n), insgesamt also nlog 2 n. Die erwarte Anzahl von I/O Zugriffen in der Vorverarbeitung der Datenstruktur ist O(log 2 n)log B n). Bei gegebenem k, einer Linie l und einer Fehlerwahrscheinlichkeit 0 < δ < 1, findet der folgende Algorithmus für gewöhnlich die k tiefsten Ebenen in H bezüglich l.

98 TryLowerPlanes (k, l,δ ) : p= log 2 (Nδ/k) Find the triangle (R p ) intersecting l if K( ) k then δ 2 scan K( ) if k planes in K( ) cross l below k then return k lowest planes in K( ) along l else f a i l else f a i l R p ist eine zufällig gewählte Menge aus H der Größe 2 p < 2Nδ/k. Das von l geschnittene Dreieck kann in O(log B 2 p = O(log B n) I/Os mit Hilfe unserer point-location Struktur gefunden werden. Wir scannen die Konfliktliste K nur, wenn sie weniger als k/δ 2 so, dass der scan maximal k/(δ 2 B) I/Os kostet. Die gesamte Laufzeit von TRYLOWEST- PLANES ist O(log B n + k/bδ 2 ) unabhängig ob eine Lösung gefunden wurde oder nicht. Nach Lemma 5.2 auf der vorherigen Seite ist die erwartete Größe von K( ) O(N/2 p ) setzt man dies in die Markovsche Ungleichung [12] ein, ist die Wahrscheinlichkeit das die Größe von K( ) k/δ 2 überschreitet in O(δ). Weiterhin tritt der Fall das weniger Ebenen in K( ) unter dem Schnitt von l und sind, nur dann ein, wenn die Ebene, die beinhaltet, eine der k niedrigsten Ebenen entlang l ist. Dieser Fall tritt k mal mit der Wahrscheinlichkeit 2 p /n ein da jede Ebene gerade diese Wahrscheinlichkeit hat in der Teilmenge R p zu sein. Die Wahrscheinlichkeit, dass TRYLOWESTPLANES fehlschlägt ist also O(δ). Um nun eine optimale erwartete Laufzeit von O(log B n + k/b) zu erreichen wird noch eine Modifikation vorgenommen. Es werden insgesamt drei unabhängige Datenstrukturen aufgebaut und Verarbeitet. Dadurch reduziert sich die Wahrscheinlichkeit für einen Fehler auf O(δ 3 ). Die Berechnung findet wie folgt statt, man ruft wiederholend auf allen drei Datenstrukturen TRYLOWESTPLANES, mit jeweils δ = 2 1, 2 2,... auf, bis irgendein Aufruf erfolgreich war. Wir definieren X i {0, 1} als eine Zufallsvariable dessen Wert 1 ist, wenn alle drei Aufrufe von TRYLOWESTPLANES mit δ = 2 i fehlgeschlagen sind. Dann ist die Gesamtanzahl I/Os maximal i Xi 1 O(log B n + 4 i k/b) Die Wahrscheinlichkeit für ein Fehlschlag war δ = 2 i hier also δ 3 = 2 3i = 8 i, damit ist die erwartete Anzahl von I/O-Operationen i 0 O(log B n + 4 i+1 k/b) 8 i = ( logb n O 8 i + 4k/B ) 2 i = O(log B n + k/b). i 0

99 6 Zusammenfassung In dieser Seminararbeit wurden geometrische Problemstellungen für den externen Speicher vorgestellt. Die besondere Behandlung geometrischer Probleme in Hinsicht auf den externen Speicher ergibt sich aus dem Speichermodell der heutigen PC-Hardware. Eine direkte Umsetzung der Konzepte von Algorithmen für den internen Speicher hat gezeigt, dass sich so nur selten (in unseren drei Beispielproblemen gar nicht) I/O-optimale externe Algorithmen erstellen lassen. Die zentrale Eigenschaft, die zur optimalen externen Algorithmen führt, ist hierbei das Ausnutzen oder Optimieren des blockweisen Lesens aus dem externen Speicher. Die hier vorgestellten Lösungen wurden so aufgebaut, dass Daten, die zusammenhängend zu bearbeiten sind auch gleichzeitig im Hauptspeicher gehalten werden können. Betrachten wir noch mal das Endpoint Domination Problem aus dem Kapitel 3 auf Seite 5, so wurde dort der Problemraum so Partitioniert, dass stets genug Blöcke für die gerade relevanten Daten im Hauptspeicher zur Verfügung standen. Bei Problemen, die sich nicht so günstig partitionieren lassen, kann der Zufall helfen. Wie im Kapitel 5 auf Seite 13 (K-nearest Neighbors Problem) ergibt sich dann eine erwarte Laufzeit für den optimalen Algorithmus.

100 Literatur [1] Pankaj K. Agarwal, Lars Arge, Jeff Erickson, Paolo G. Franciosa, and Jeffry Scott Vitter. Efficient searching with linear constraints. pages , [2] Lars Arge, Darren Erik Vengroff, and Jeffrey Scott Vitter. External-memory algorithms for processing line segments in geographic information systems (extended abstract). In ESA 95: Proceedings of the Third Annual European Symposium on Algorithms, pages , London, UK, Springer-Verlag. [3] Christian Breimann and Jan Vahrenhold. Algorithms for Memory Hierarchies: Advanced Lectures, chapter 6. External Memory Computational Geometry Revisited. Lecture Notes in Computer Science. Springer Berlin / Heidelberg, [4] Chazelle, Edelsbrunner, Guibas, and Sharir. Algorithms for bichromatic linesegment problems and polyhedral terrains. ALGRTHMICA: Algorithmica, 11, [5] Bernard Chazelle and Leonidas J. Guibas. Fractional cascading: I. a data structuring technique. Algorithmica, 1(2): , [6] Andreas Crauser, Paolo Ferragina, Kurt Mehlhorn, Ulrich Meyer, and Edgar A. Ramos. Randomized external-memory algorithms for some geometric problems. In Symposium on Computational Geometry, pages , [7] Herbert Edelsbrunner. Algorithms in combinatorial geometry. Springer-Verlag New York, Inc., New York, NY, USA, [8] Michael T. Goodrich, Jyh-Jong Tsay, Darren E. Vengroff, and Jeffrey Scott Vitter. External-memory computational geometry. In Proceedings of the 34th Annual Symposium on Foundations of Computer Science, pages , [9] T.G. Graf and K.H. Hinrichs. Plane-sweep construction of proximity graphs. PhD thesis, Fachbereich Mathematik, Westfälische Wilhelms-Universität Münster, Germany, [10] Massachusetts Joseph O Rourke Smith College. Data structures and algorithms 3. Springer-Verlag New York, Inc., New York, NY, USA, second edition, [11] David G. Kirkpatrick. Optimal search in planar subdivisions. SIAM J. Comput., 12(1):28 35, [12] Ulrich Krengel. Einführung in die Wahrscheinlichkeitstheorie und Statistik. Vieweg, Wiesbaden, Deutschland, sixth edition, [13] Germany Kurt Mehlhorn Universität des Saarlandes, Saarbrücken. Computational Geometry in C. Springer-Verlag New York, Inc., New York, NY, USA, [14] K. Mulmuley. Computational Geometry. An Introduction Through Randomized Algorithms. Prentice Hall, 1994.

101 [15] Raimund Seidel. A simple and fast incremental randomized algorithm for computing trapezoidal decompositions and for triangulating polygons. Comput. Geom., 1:51 64, 1991.

102

103 97 Volltextindizes im externen Speicher André Braun 1 Einleitung In der heutigen Zeit haben wir oft mit sehr großen Texten oder Textsammlungen zu tun. Einige Beispiele dafür sind digitale Bibliotheken, Lexika oder Biosequenzen. Für das schnelle Extrahieren von benötigter Information ist eine effiziente Stringsuche notwendig. Eine Möglichkeit dafür stellen die so genannten Volltextindizes dar. Die Grundidee ist das Speichern aller Suffixe eines Textes in einer Indexstruktur, z.b. einem sortierten Array oder einem Baum. In diesem Index kann dann effizient nach Substrings gesucht werden. Die meisten Volltextindizes wurden für das RAM-Modell entwickelt. Oft passen die Texte aber nicht komplett in den Hauptspeicher. Außerdem muss man bedenken, dass die Größe eines Volltextindexes etwa das 4- bis 20-fache des Originaltextes beträgt. Auch eine dauerhafte Besetzung eines Teils des Hauptspeichers durch einen Volltextindex wäre ungünstig. Aufgrund dessen sind Anpassungen der Volltextindizes an den externen Speicher notwendig. Man versucht eine möglichst gute Speicherlokalität zu gewährleisten und somit die Anzahl der sehr kostspieligen I/O-Zugriffe zu minimieren. In dieser Ausarbeitung werde ich einige solche Volltextindizes für den externen Speicher und die Suche darin vorstellen. Außerdem werde ich einen Algorithmus für den effizienten Aufbau solcher Indizes im externen Speicher vorstellen. Für das Verständnis dieser Algorithmen werden einige Grundlagen benötigt, die ich in den ersten Kapiteln vorstellen werde. 2 Formale Beschreibung des Problems Das Rechenmodell, welches in dieser Arbeit benutzt wird, ist das Standardmodell, welches in [1] beschrieben wird. Hier gebe ich nur eine kurze Beschreibung. Als erstes werden einige Notationen eingeführt. Ein Alphabet Σ ist eine geordnete Menge von Symbolen. Ein String S ist ein Array von Symbolen, S[1, n] = S[1]S[2]...S[n]. Ferner definieren wir i und j, mit 1 i j N. Es gilt: S[1, j] ist Präfix von S

104 98 S[i, S ] ist Suffix von S S[i, j] ist Substring von S Mit Σ werden alle Zeichenkette über dem Alphabet Σ bezeichnet. Folgende Parameter werden benutzt: N = Anzahl der Symbole im Text M = Anzahl der Symbole, die in den internen Speicher passen B = Anzahl der Symbole, die in einen Speicherblock im externen Speicher passen Außerdem werden folgende Kurznotationen benutzt: scan(n) = Θ(N/B) sort(n) = Θ((N/B) log M/B (N/B)) search(n) = Θ(log B N) Zusätzlich verwenden wir folgende Parameter: K = Anzahl der Strings im Text Z = Größe des Abfrageergebnisses (Anzahl der Treffer) P = Anzahl der Symbole in einem Suchstring P S = Anzahl der Symbole in einem eingefügten/gelöschten String S Aufgabe (exaktes Stringmatching): Sei T eine Menge von K Strings in Σ, N ist die totale Länge aller Strings in T. Gegeben ein Musterstring P, finde alle Vorkommen von P in den Strings von T. Bei der statischen Version des Problems wird nur die Stringsuche betrachtet, bei der dynamischen Version braucht man zusätzlich eine Unterstützung für das Einfügen/Löschen von Strings. Alle in dieser Arbeit betrachteten Volltextindizes haben eine lineare Speicherkomplexität. Der Schwerpunkt wird dementsprechend auf der Zeitkomplexität der Stringsuche bzw. der Updates von Indizes liegen. 3 Basistechniken für den internen Speicher Die meisten Volltextindizes sind Varianten der drei Datenstrukturen: der Suffixarrays [2, 4], der Suffixbäume [3] und der direkten azyklischen Wortgraphen (DAWGs), wobei es meines Wissens bis heute keine Anpassungen der DAWGs an den externen Speicher gibt. Die Suffixarrays und Suffixbäume bilden demzufolge die Basis für Datenstrukturen für den externen Speicher, die in dieser Arbeit beschrieben werden. Lassen Sie mich mit einer Beobachtung starten, die der Benutzung aller Volltextindizes zugrunde liegt. Ein Substring ist immer ein Präfix von einem Suffix. Oder formaler: Wenn ein Vorkommen von einem Musterstring P in einem String an der Position i beginnt, dann ist P ein Präfix vom Suffix S[i, S ]. Somit können wir alle Vorkommen von P durch eine Präfixsuche

105 99 auf der Menge aller Suffixe des Textes finden. Also ist eine Datenstruktur, die die Menge der Suffixe eines Textes speichert und die Präfixsuche darauf unterstützt, ein Volltextindex. 3.1 Suffixarrays Die einfachste Datenstruktur, die eine effiziente Präfixsuche unterstützt, ist ein lexikographisch sortiertes Array. Ein sortiertes Array SA T von Zeigern auf die Suffixe von T nennt man ein Suffixarray. Für die Stringsuche in einem Suffixarray verwendet man die binäre Suche, die bekannterweise O(log 2 N) Vergleiche benötigt. Insgesamt benötigt man die Zeit O( P log 2 N) im Worst Case, da bei jedem Stringvergleich der Musterstring Zeichen für Zeichen mit dem jeweiligen String aus dem Array verglichen wird. In [4] zeigen Manber und Myers, wie man mit Hilfe der längsten gemeinsamen Präfixe eine bessere Zeitkomplexität von O( P +log 2 N) erreichen kann. Dieselben Autoren zeigen, wie man ein Suffix-Array in Zeit O(N log 2 N) konstruieren kann. Der Nachteil von Suffixarrays ist, dass sie keine effizienten Updates unterstützen und somit nur für statische Dokumente verwendbar sind. 3.2 Tries und Suffixbäume Eine andere einfache Datenstruktur zum Speichern von Strings ist ein so genannter Trie (von retrieval). Ein Trie ist ein Baum, an dessen Kanten Zeichen gespeichert werden (siehe Abb. 1). Ein Knoten repräsentiert die Konkatenation der Kantenlabels auf dem Pfad von der Wurzel bis zu diesem Knoten. Für eine präfixfreie Menge von Strings sind alle Knoten, die Strings repräsentieren, die Blätter des Trie. Abbildung 1. Ein Trie für T = {index, inline, tattoo, tempo} Eine Modifikation des Trie ist ein kompakter Trie. Der Unterschied zu einem Trie ist, dass man Knoten, die jeweils nur einen Nachfolger besitzen, zu jeweils einem Knoten zusammenfasst (siehe Abb. 2). An einer Kante werden also auch Zeichenketten anstatt nur einzelner Zeichen gespeichert. Somit wird die Anzahl der Knoten und dementsprechend der Speicherbedarf reduziert. Ein Suffixbaum für einen Text T ist ein kompakter Trie für die Menge der Suffixe von T (siehe. Abb. 3). Dabei ist es üblich, dass man ans Ende jedes

106 100 Abbildung 2. Ein Kompakter Trie für T = {index, inline, tattoo, tempo} Strings ein spezielles Zeichen, z.b. $ setzt, damit die Stringmenge präfixfrei wird. Die Suche in dem Suffixbaum geschieht, indem man in dem Baum von der Wurzel aus nach unten wandert und dabei die Zeichenketten an den Kanten mit den entsprechenden Zeichen in dem Suchstring vergleicht. Erreicht man das Ende des Suchstrings, so ist der Knoten, in dem man sich befindet, die Wurzel des Unterbaumes, dessen Blätter die Suffixe repräsentieren, welche den Suchstring als einen gemeinsamen Präfix enthalten. Also entspricht die Anzahl dieser Blätter der Anzahl der Treffer in T (falls man sich am Ende der Suche in einem Blatt befindet, hat man als Sonderfall genau einen Treffer). Die Zeitkomplexität der Suche ist O(P ) für das Durchlaufen des Baumes von der Wurzel aus unter der Bedingung, dass das Alphabet konstant ist, plus O(Z) für das Absuchen des Unterbaumes. Abbildung 3. Suffixbaum für T = {banana$} mit SA(banana$) = {$, a$, ana$, anana$, banana$, na$, nana$} 4 Basistechniken für den externen Speicher In diesem Kapitel beschreibe ich einige Erweiterungen der bisher vorgestellten Techniken für eine effizientere Nutzung im externen Speicher. Die Suffixarrays und -Bäume speichern keine Strings, sondern nur Zeiger darauf. Jedes Mal wenn die Strings gebraucht werden, wird auf den Text

107 101 zugegriffen (wahlfreie Zugriffe). Das heißt, die Performance der Algorithmen ist ziemlich schlecht, falls der Text sich auf dem externen Speicher befindet. Die zwei im Weiteren vorgestellten Techniken versuchen diesen Nachteil zu beheben. Die erste Technik ist ein so genannter Patricia Trie, welche den kompakten Trie auf den externen Speicher anpasst [5]. Der Unterschied zwischen einem Patricia Trie und einem kompakten Trie ist, dass in dem Patricia Trie die Kantenbeschriftung nur den ersten Buchstaben und die Länge (übersprungene Zeichen) der zugehörigen Beschriftung des kompakten Trie enthält. Der Patricia Trie P T T für die Menge der Suffixe des Textes T heißt Pat-Baum (Abb. 4). Abbildung 4. Pat-Baum für T = {banana$} Die Grundidee der Patricia Tries bzw. der Pat-Bäume ist es, den Zugriff auf den Text so lange wie möglich hinauszuzögern. Man hat dann keine wahlfreien Zugriffe mehr, sondern nur einen kontinuierlichen Zugriff am Ende der Suche. Die Suche selber ist ähnlich der Suche in einem Suffixbaum mit dem Unteschied, dass hier nur das erste Zeichen an einer Kante mit dem entsprechenden Zeichen im Suchstring verglichen wird. Das heißt, man vergleicht nur die Zeichen die tatsächlich im Patricia Trie gespeichert werden, also im Hauptspeicher zur Verfügung stehen. Die anderen Zeichen werden zunächst übersprungen. Falls die erste Phase der Suche erfolgreich verlaufen ist, d.h. man hat das Ende des Suchstrings erreicht, betrachtet man den Knoten in dem man sich nun befindet als Wurzel eines Unterbaums. Man weiß, dass alle Strings (Blätter) dieses Unterbaums ein gemeinsames Präfix haben, welches entweder dem Suchstring entspricht oder nicht. Um dies zu überprüfen, benötigt man eine I/O-Operation, um einen dieser Strings in den Hauptspeicher zu laden und vergleicht ihn dann mit dem Suchstring. Falls die beiden gleich sind, sind alle Blätter des Unterbaumes die Treffer der durchgeführten Suchabfrage, falls nicht, gibt es keine Treffer. Die Komplexität dieser Suche im internen Speicher ist immer noch O( P + Z) wie bei den Suffixbäumen, die Komplexität der I/O-Zugriffe ist nun aber O(1). Die zweite Technik ist das so genannte Lexicographic Naming, vorgestellt in [6]. Bei dieser Technik wird jedem String ein Integerwert (Name) zugeordnet.

108 102 Die arithmetische Ordnung auf den Namen entspricht der lexikographischen Ordnung auf den dazu gehörigen Strings. Dadurch kann man beliebig lange Strings vergleichen, ohne auf sie direkt zugreifen zu müssen, indem man stattdessen die entsprechenden Namen benutzt. Die Datenstruktur mit den Namen kann man bilden, indem man die Stringmenge S sortiert und den Rang eines Strings als seinen Namen nutzt. Der Rang ist dabei die Anzahl der lexikographisch kleineren Strings plus eins. 5 String B-Bäume In diesem Kapitel stelle ich die String B-Bäume vor, eingeführt von Ferragina und Grossi [7]. Diese Indexstruktur vereinigt die Vorteile von B-Bäumen und Patricia Tries und ermöglicht eine effiziente Suche im externen Speicher. Andere I/O-effiziente Strukturen für Volltextindizes, die in dieser Arbeit nicht vorgestellt werden, sind z.b die Hierarchie von Indizes von Baeza-Yates et al [8] und die kompakten Pat-Bäume von Clark und Munro [9]. Die String B-Bäume sind eine Kombination von B-Bäumen und Patricia Tries. Ein B-Baum ist eine sehr verbreitete Datenstruktur, wenn es um Datenverwaltung auf dem externen Speicher geht (z.b. Datenbanken oder Dateisysteme). Der Vorteil gegenüber anderen Baumstrukturen ist, dass man aufgrund einer breiten Verzweigung nur wenige Ebenen im Baum und somit nur wenige kostspielige Speicherzugriffe hat. Der Verzweigungsgrad wird so gewählt, dass ein Knoten maximal einen kompletten Speicherblock belegt. Die variable Schlüsselanzahl pro Knoten verhindert ein ständiges Rebalancieren des Baumes. Ein Patricia Trie kann auf der anderen Seite beliebig lange Strings effektiv verwalten falls er komplett in den Hauptspeicher passt. Die Suche braucht in diesem Fall nur eine konstante Anzahl an I/O-Zugriffen. Man will nun die Vorteile der beiden Datenstrukturen kombinieren, indem man Patricia Tries als Knoten für einen B-Baum verwendet und somit beliebig große Texte effektiv verwalten kann. Im Folgenden beschreibe ich die Struktur eines String B-Baumes. Abbildung 5. Der logische Aufbau eines Knotens π, der g Kinder hat Gegeben ist eine Menge S = s 1,..., s N bestehend aus N Strings (Suffixen). Jeder Knoten π besitzt eine geordnete Menge von Strings S π, so dass

109 103 b S π 2b, b = Θ(B). Ein Knoten π besitzt d(π) Kinder mit b/2 d(π) b. Der ganz links bzw. ganz rechts stehende Knoten in S π wird mit L(π) bzw. R(π) bezeichnet. Die Menge S π bekommt man nun, indem man die Kinder von π von links nach rechts durchgeht und dabei das jeweilige L(σ) und R(σ) für ein Kind σ nach π kopiert (Abb. 5). Alle Schlüssel befinden sich lexikographisch sortiert in den Blättern des Baumes. Das heißt, die ganze Struktur ist B + -Baum ähnlich. Der Unterschied zu einem B + -Baum ist, dass die internen Knoten nicht nur Zeiger auf Kindelemente, sondern auch einige Kopien der Schlüssel enthalten (Abb. 6). Abbildung 6. Beispiel für eine String B-Baum ähnliche Struktur und ihre Eingabemenge Die beschriebene Struktur ist noch nicht der endgültige String B-Baum, da hier noch nicht die Patricia Tries für die Knoten benutzt werden (stattdessen ein sortiertes Array). Ich werde den Ablauf der Suche zuerst auf dieser einfacheren Datenstruktur beschreiben. Danach ersetze ich die Arrays in den Knoten durch Patricia Tries und zeige, welchen Vorteil es bei der Laufzeit bringen wird. Die Suche, die hier vorgestellt wird, basiert auf zwei Beobachtungen, die zuerst von Manber und Myers [4] aufgestellt wurden. Sei K ein sortiertes

110 104 Array. Die erste Beobachtung besagt, dass alle Strings mit dem gemeinsamen Präfix P einen zusammenhängenden Bereich von K einnehmen. Die zweite Beobachtung besagt, dass sich der ganz links stehende String in diesem Bereich rechts neben der Position von P befindet und analog der ganz rechts stehende links neben der Position von P$ wobei $ in diesem Fall ein Symbol ist, welches größer ist als jedes andere Symbol im Alphabet (siehe Abb. 7). Da man sehr leicht ein sortiertes Array aller Strings erhält, indem man die Blätter des String B-Baumes von links nach rechts aneinander hängt, kann man diese beiden Beobachtungen bei der Suche benutzen. Die Position von P in K werde Abbildung 7. Die Positionen von an und an$ im SA{banana} ich durch ein Tupel (τ, j) repräsentieren. Dabei ist τ das entsprechende Blatt und j die Position innerhalb des Blattes. In Abb. 8 ist der Pseudocode für die Suche dargestellt. Die Suche beginnt damit, dass man zwei Trivialfälle überprüft. Es wird geprüft, ob P größer (Schritt 1) oder kleiner (Schritt 2) ist als alle anderen Strings in K. Ist beides nicht der Fall, beginnt man mit der eigentlichen Suche, indem man π := root setzt (Schritt 3) und dann den Baum von oben nach unten durchläuft und dabei die Invariante L(π) < P < R(π) für jeden besuchten Knoten π einhält (Schritte 4-8). Beim Besuchen des Knotens π lädt man den entsprechenden Speicherblock in den internen Speicher und wendet die Prozedur PT-Search an, die im Wesentlichen eine binäre Suche darstellt. Dabei sucht man nach der Position von P in der Stringmenge S π, genauer gesagt sucht man nach zwei Nachbarstrings, die die Position von P bestimmen: K j 1 < P < K j. Falls π ein Blatt ist, ist man mit der Suche fertig. Falls es ein interner Knoten ist, muss man zwei Fälle unterscheiden: 1. Wenn die Strings K j 1 und K j zu zwei verschiedenen Kindern von π gehören, dann müssen sie aufgrund des Aufbaus des B-Baumes Nachbarstrings sein, also K j 1 = R(σ ) und K j = L(σ) für zwei Nachbar- Kindknoten σ und σ. Damit ist die Position von P eindeutig bestimmt. Man wählt für t das ganz links liegende Blatt, welches ein Kind von σ ist und für j die erste Position in S τ, da L(τ) = L(σ) = K j. 2. Wenn K j 1 und K j zum gleichen Knoten gehören, das heißt K j 1 = L(σ) und K j = R(σ) für einen Knoten σ, dann wird π = σ gesetzt und die Suche wird fortgesetzt. Nun kommen wir zu der Laufzeitanalyse des Algorithmus. Die Prozedur PT-Search ist eine binäre Suche von P auf der Menge S π und benötigt dementsprechend O(log 2 S π ) = O(log 2 B) Stringvergleiche. Da sich in einem Knoten nur Zeiger auf die tatsächlichen Strings befinden, muss jeder String, den man prüfen will, geladen werden. Dafür braucht man zusätzlich O(p/B) I/Os pro String und somit ist die Gesamtlaufzeit für PT-Search O(p/B log 2 B) I/Os. Insgesamt wird PT-Search H mal aufgerufen. O(H) kann man auch als

111 105 Abbildung 8. Der Pseudocode der Suche der Position von P in K O(log B N) schreiben, da jeder Knoten Θ(B) Kinder hat. Alle anderen Schritte benötigen eine konstante Zeit. Somit ist die Gesamtlaufzeit des Algorithmus O(p/B log B N log 2 B) = O(p/B log 2 N) I/Os. Zusätzlich benötigt man noch O(Z/B) I/Os, um die gefundenen Strings zu laden. Nun werden die Suffix-Arrays in den Knoten durch Patricia Tries ersetzt, um die endgültige Datenstruktur zu bekommen. In Kapitel 4 haben Sie bereits gesehen, dass Patricia Tries nur einen Diskzugriff für die Suche benötigen. Somit entfällt die binäre Suche in den Knoten. Der Suchstring wird einmal geladen (O(p/B)), man wandert in dem Baum von der Wurzel bis zu einem Blatt (O(log B N)) und am Ende werden die gefundenen Strings geladen (O(Z/B)). Die Gesamtlaufzeit der Suche in einem String B-Baum ist also O(p/B + log B N + Z/B) I/Os. Der eine Vorteil von String B-Bäumen ist, dass sie dynamisch sind. Das heißt, man kann neue Strings hinzufügen, ohne dass man reservierten Speicherplatz braucht. Zum Einfügen bzw. Löschen eines Strings der Länge m sucht man dessen Position. Die Komplexität der Suche ist O(m/B + log B N) I/Os (da das Laden der gefundenen Strings entfällt). Es kann dabei passieren, dass der Knoten mit dem eingefügten String zu voll wird, d.h. mehr als 2b Strings enthält. In diesem Fall muss der Baum rebalanciert werden. Der volle Knoten wird in zwei Knoten aufgeteilt. Der Elternknoten bekommt zwei neue Zeiger. Falls er dann auch zu voll wird, wird er ebenfalls aufgeteilt. Im schlimmsten Fall kann sich das bis ganz nach oben fortsetzen. Die Wurzel wird als letzter Knoten gesplittet und es entsteht eine neue Wurzel oberhalb davon. Der Baum wächst um eine Ebene. Beim Löschen eines Strings kann das Gegenteil passieren. Der Knoten wird zu leer (weniger als b Strings) und wird mit einem Nachbarknoten zusammengefasst. Der Elternknoten hat dann zwei Zeiger weniger und muss eventuell ebenfalls mit einem anderen Knoten zusammengefasst werden. Im Extremfall schrumpft der Baum um eine Ebene. Die Kosten für das Rebalancieren eines B-Baumes betragen O(log B N). Somit betragen die Kosten für das Enfügen/Löschen eines Strings O(m/B +log B N) I/Os.

112 106 Da man beim Einfügen/Löschen eines Strings in einen String B-Baum alle seine Suffixe einfügen/löschen muss, muss man die Position jedes Suffixes suchen. Da ein String der Länge m genau m Suffixe besitzt, ist die Gesamtkomplexität für das Einfügen/Löschen eines Strings O(m/B + m(log B N)) I/Os. 6 I/O-effiziente Konstruktion von Volltextindizes Neben der Laufzeit der Suchalgorithmen und der Speichereffizienz ist die Zeit, die für die Konstruktion eines Volltextindexes benötigt wird, ein weiteres wichtiges Kriterium. In der Tat stellt oft gerade die Konstruktion eines Indexes den Flaschenhals einer Anwendung dar. In diesem Kapitel möchte ich einen der I/O-effizienten Konstruktionsalgorithmen für Volltextindizes beschreiben. Es ist der Doubling Algorithmus, vorgestellt von Arge et al. [10] und modifiziert von Crauser und Ferragina [11]. Bevor ich auf den Algorithmus eingehe, sei an dieser Stelle eine Bemerkung angebracht. Alle Formen der Volltextindizes, die ich in dieser Arbeit vorgestellt habe, die Suffixarrays, Suffixbäume, Pat-Bäume und auch String B-Bäume, können ineinander in O(sort(N)) I/Os überführt werden. Das heißt, ein Algorithmus, der einen dieser Indizes effizient konstruieren kann, ist für die anderen ebenfalls effizient. Der Doubling Algorithmus basiert auf der Technik des Lexicographical Naming (s. Kapitel 4) und wird für den Aufbau von Suffixarrays verwendet. Sei r k (i) der Name eines Substrings der Länge 2 k, der an Position i beginnt. r k (i) ist also die Anzahl der lexikographisch kleineren Substrings plus eins und stellt dementsprechend die Position des entsprechenden Suffixes im Suffixarray dar. Der Algorithmus konstruiert r k für k = 1, 2,..., log 2 N. Substrings kleiner als 2 k werden mit $s (Spezialsymbol, kleiner als jedes andere Symbol im Alphabet) aufgefüllt (Abb. 9) Abbildung 9. Der Doubling Algorithmus für den Text banana Am Anfang scannt der Algorithmus den Text, der hier als ein einziger String betrachtet wird. Für jede Position i wird ein entsprechendes Tripel (r k 1 (i), r k 1 (i + 2 k 1 ), i) konstruiert. Nach dieser Initialisierungsphase beginnt die Hauptschleife des Algorithmus mit der Laufvariablen k. Bei jedem Durchlauf wird das neue r k (i) berechnet. Das geschieht in vier Schritten, die folgendermaßen aussehen: 1. Sortiere die Tripel nach den ersten beiden Komponenten (was zum Sortieren der Substrings der Länge 2 k equivalent ist).

113 Gehe alle Tripel der Reihe nach durch und weise ihnen aufsteigende Integerzahlen als r k (i) zu, d.h. ersetzte r k (i 1) durch r k (i). Tripel, bei denen die ersten beiden Komponenten (r k 1 (i), r k 1 (i + 2 k 1 ) gleich waren, bekommen das gleiche r k (i) zugewiesen. 3. Sortiere nach i, d.h. bringe die Tripel in die initiale Reihenfolge. 4. Gehe alle Tripel durch und aktualisiere die zweite Komponente, d.h. ersetze r k 1 (i + 2 k 1 ) durch r k (i + 2 k ). Das kann man machen, weil in Schritt 2 alle r k (i)s berechnet wurden. Um den Algorithmus besser zu verstehen, betrachten wir an dieser Stelle einen Durchlauf des Algorithmus mit konkreten Werten. Nehmen wir an, dass r 1 bereits berechnet wurde, r 2 soll mit dem nächsten Durchlauf der Schleife berechnet werden. Wir betrachten den String, der an Position zwei beginnt, oder genauer gesagt das Tripel, das diesen String ( anan bei k=2) repräsentiert (siehe Abb. 9). Vor Beginn der Schleife ist r k (i = 2) = Infolge der Sortierung kommt das Tripel (2, 2, 2) an die 3. Position. 2. r 2 (2) := 3, das Tripel wird also zu (3, 2, 2) 3. Das Tripel kommt wieder an die 2. Position (Sortierung nach i) 4. Die zweite Komponente wird durch r 2 ( = 6) = 1 ersetzt, das Tripel wird also zu (3, 1, 2) Nach log 2 N Durchläufen bekommt man ein Suffixarray, in dem die Suffixe durch den zugewiesenen Namen lexikographisch sortiert sind (Abb. 9), wobei es eigentlich das Inverse eines Suffixarrays ist, wie es in 3.1 vorgestellt wurde. Kommen wir nun zu der Laufzeit des Algorithmus. Für die Schritte 1 und 3 benötigt man jeweils O(sort(N)) I/Os, für 2 und 4 jeweils O(scan(N)) I/Os. Da die Schleife log 2 N Durchläufe macht, werden insgesamt O(sort(N) log 2 N) I/Os benötigt. Man kann diese Laufzeit noch verbessern, indem man berücksichtigt, dass Namen, die nur ein einziges Mal vorkommen, nicht mehr geändert werden. In dem Beispiel wird in r 0 der Name 4 nur einmal zugewiesen. Somit bleibt es bis zum Ende des Algorithmus unverändert, es gilt also: r h (i) = r k (i) für alle h > k. Tripel mit der ersten Komponente, die dieser Regel entspricht, nennt man fertige Tripel. Solche Tripel werden bei weiteren Durchläufen der Schleife nicht mehr berücksichtigt. Man lagert sie in eine separate Datei aus und benutzt sie am Ende des Algorithmus zur Konstruktion des Suffixarrays. Zwar ist die Worst Case Laufzeit immer noch O(sort(N) log 2 N), z.b. bei einem Text T = aaa...a, da aber in der Praxis die Anzahl der fertigen Tripel schnell ansteigt, ist die tatsächliche Laufzeit meistens viel besser. 7 Zusammenfassung In dieser Arbeit habe ich die Volltextindizes vorgestellt. Es ist eine Datenstruktur, die die Stringsuche in einem Text unterstützt. Dabei werden für jeden String, der im Text vorkommt, alle seine Suffixe gespeichert. Es bedeutet einen größeren Speicherbedarf als bei den anderen Suchstrukturen (z.b. invertierte Dateien), ermöglicht aber einfache und schnelle Suchalgorithmen.

114 108 Außerdem können Volltextindizes bei Texten, die aus einem einzigen String bestehen (z.b. DNS-Sequenzen), eingesetzt werden, was bei anderen Arten von Indizes nicht möglich ist. In dieser Arbeit habe ich nur die einfache Stringabfrage betrachtet, auch das exakte Stringmatching genannt. Eine komplexere Form der Abfrage auf dem externen Speicher, das so genannte approximative Stringmatching, bleibt bis heute ein offenes Problem. Ein weiteres Thema, welches ich in dieser Ausarbeitung nicht bearbeitet habe, sind die Kompressionsalgorithmen, die zur Reduzierung des benötigten Speichers bei Volltextindizes eingesetzt werden können. Literatur 1. P. Sanders. Memory Hierachies - Models and Lower Bounds. In U. Meyer et al. Algorithms for Memory Hierarchies, S Springer Verlag, G. Gonnet, R. Baeza-Yates und T. Snider. New indices for text: PAT trees and PAT arrays. In W. B. Frakes and R. Baeza-Yates [Hrsg.], Information Retrieval: Data Structures and Algorithms. Prentice-Hall, P. Weiner. Linear pattern matching algorithm. In Proceedings of the 14th Symposium on Switching and Automata Theory, S IEEE, U. Manber und G. Myers. Suffix arrays: A new method for on-line string searches. SIAM Journal of Computing, 22(5): , D. R. Morrison. PATRICIA - practical algorithm to retrieve information coded in alphanumeric. Journal of the ACM, 15(4): , R. M. Karp, R. E. Miller und A. L. Rosenberg. Rapid identification of repeated patterns in strings, trees and arrays. In Proceedings of the 4th Annual Symposium on Theory of Computing, S ACM, P. Ferragina und R. Grossi. The string B-tree: A new data structure for string search in external memory and its applications. Journal of the ACM, 46(2): , R. Baeza-Yates, E. F. Barbosa und N. Ziviani. Hierarchies of indices for text searching. Journal of Information Systems, 21(6): , D. R. Clark und J. I. Munro. Efficient suffix trees on secondary storage (extended abstract). In Proceedings of the 7th Annual Symposium on Discrete Algorithms, S ACM-SIAM, L. A. Arge, P. Ferragina, R. Grossi und J. S. Vitter. On sorting strings in external memory (extended abstract). In Proceedings of the 29th Annual Symposium on Theory of Computing, S ACM, A. Crauser and P. Ferragina. A theoretical and experimental study on the construction of suffix arrays in external memory. Algorithmica, 32(1):1-35, 2002.

115 109 Algorithmen für Hardware Caches und TLBs Stefan Frank 1 Einleitung Bei der Entwicklung von neuen Algorithmen (für z.b. das Durchsuchen oder Sortieren von Daten) ist es für die praktische Anwendbarkeit unerlässlich, auch Aspekte der Rechnerarchitektur zu berücksichtigen. So kann durch entsprechende Optimierung ein deutlicher Geschwindigkeitsvorteil erreicht werden, wie diese Ausarbeitung exemplarisch zeigen wird. Dabei wird das Ziel verfolgt die Anzahl der Zugriffe auf den Speicher möglichst gering zu halten. Wie in Kapitel 8 des - dieser Ausarbeitung zugrunde liegenden - Buches [3] ausgeführt wird, haben zwar heutige CPUs (Central Processing Unit) eine Taktfrequenz von mindesten 2 GHz, jedoch liegen die Zugriffszeiten auf den RAM bei ca. 60 ns. Die CPU müsste also mindestens 120 Taktzyklen warten, bis ein angefordertes Datum aus dem RAM (Random Access Memory) in die lokalen Register des Prozessors geladen wurde. Es ist somit leicht verständlich, warum eine Reduzierung der RAM-Anfragen eine Verkürzung der Rechenzeit zur Folge haben kann. Um ein tiefer gehendes Verständnis für die Analyse von Algorithmen bzgl. ihrer Zugriffe auf den Speicher zu erhalten, wird zunächst in Kapitel 2 eine kurze Einführung in die Hardware-Struktur heutiger, moderner Rechner gegeben. Hierauf aufbauend werden Modelle vorgestellt, welche die Hardware abstrahieren und in denen Zugriffe berechnet werden können. Die Kapitel 3 und 4 präsentieren anhand zweier konkreter Beispiele aus den Bereichen Sortieren und Suchen wie diese Modelle angewendet werden können. Die Beispiele zeigen außerdem, dass sich der Aufwand lohnt, den man bzgl. der Optimierung von Algorithmen unter Berücksichtigung der Hardware betreibt, denn teilweise kann eine deutliche Verkürzung der Rechenzeit erreicht werden. 2 Rechnerarchitektur und die sie abstrahierenden Modelle An dieser Stelle werden zunächst Grundlagen der Rechnerarchitektur besprochen, welche für jene Modelle, welche die Hardware abstrahiert darstellen, von Bedeutung sind. Dabei stehen die sog. Caches und TLBs (Translation Lookaside Buffer) im Mittelpunkt der Betrachtungen.

116 Cache-Speicher Fordert der Prozessor ein Datum an, so werden diese vom Massenspeicher in den RAM geladen und von dort an die CPU übermittelt. Wegen der, bereits in der Einleitung angesprochenen Diskrepanz zwischen Taktfrequenz der CPU und der Zugriffszeit des RAMs wurden schnelle Zwischenspeicher eingeführt, auf welchen zwar ein sehr schneller Zugriff möglich ist, jedoch nur eine (im Gegensatz zum RAM) beschränkte Speicherkapazität besitzt. Diese sog. Caches sind zwischen CPU und RAM geschaltet. Fordert die CPU ein Datum an, so werden die Daten zunächst in den Cache geladen und von dort an die CPU übermittelt. Dabei gibt es i.d.r. mindestens zwei, manchmal auch drei Cache-Stufen (sog. Level-1-, Level-2- und Level-3-Caches). Dabei ist der Level-1-Cache sehr nahe bei der CPU (oftmals auf dem gleichen Chip) und kann die gewünschten Daten häufig schon in einem CPU-Taktzyklus zur Verfügung stellen, hat dafür aber nur eine Speicherkapazität von 8 KB bis 32 KB. Der Level-2-Cache benötigt hingegen schon 5-13 Prozessorzyklen, hat dafür aber eine Speicherkapazität von bis zu 4 MB (Zahlen entnommen aus: [1], Seite 310)). Befinden sich die angeforderten Daten im Cache, so liegt ein sog. Cache-Hit vor, andernfalls ein Cache-Miss und die Daten müssen aus dem RAM oder einem hierarchisch tiefer gelegenen Cache angefordert werden. Der Cache ist in sog. Sets unterteilt, wobei jeder Set eine oder mehrere Cachelines halten kann. Die Anzahl der Cachelines pro Set bezeichnet man als sog. Wege-Assoziativität, bei 4 Cachlines spricht man von einem 4-Wege-assoziativen Speicher. Dabei ist eine Cacheline die kleinste Einheit, welche an den Cache übermittelt wird und ist i.d.r. 32 bis 64 Bytes lang. Hierdurch versucht man spatiale (räumliche) Lokalität auszunutzen, da ein angefordertes Datum B, welches direkt auf ein vorher angefordertes Datum A folgt, auch oftmals räumlich (also im Speicher) nebeneinander liegt. Datum B hat man innerhalb der Cacheline somit automatisch zusammen mit Datum A übertragen und muss dieses nicht extra in den Cache laden. Das Datum, welches im Speicher an der Stelle x liegt, gehört zur Cacheline xdivb, wobei B die Anzahl der Dateneinheiten (z.b. 32-Bit-Integer-Words) ist, welche in eine Cacheline passen. Wird das Datum x angefordert, wird somit die gesamte Cacheline in den Cache geladen und zwar in jenes Set, welches sich berechnet durch (xdivb)mods, wobei S die Anzahl der Sets ist. Sind alle Cachelines innerhalb eines Sets belegt, so muss eine Cacheline aus dem Cache verdrängt werden, obwohl in einem anderen Set des Speichers noch eine Cacheline frei wäre. Häufig wird jene Line verdrängt, welche am längsten nicht mehr benutzt wurde (Least-Recently-Used (LRU) - Verfahren). Wird diese verdrängte Cacheline später wieder angefordert, so spricht man von einem Conflice miss, erfolgt die Verdrängung weil der gesamte Speicher belegt ist, so nennt man das spätere Wiederanfordern dieser Line Capacity miss. Wird das Datum erstmalig angefordert, so muss dieses zu einem Miss führen und man spricht dann von einem Compulsory miss. Temporale Lokalität ist daher ein weiteres Ziel, welches man verfolgt: Daten, die oftmals hintereinander aufgerufen werden sollen auch im Cache bleiben und nicht verdrängt werden, um so Misses zu reduzieren. Für weitere Informationen bzgl. Caches sei an dieser Stelle auf Kapitel 9 des Buches [2] verwiesen

117 Der Translation Lookaside Buffer Heutige Software benötigt häufig mehr Speicher als physikalisch zur Verfügung steht. Weiterhin wird i.d.r. nicht nur ein Prozess ausgeführt, sondern mehrere. Hieraus resultierend wurde der virtuelle Speicher und der zugehörige logische Adressraum eingeführt, wobei jeder Prozess seinen eigenen Adressraum erhält, welcher wiederum in kleinere gleichgroße Segmente, den sog. Pages unterteilt ist. Die CPU fordert ein Datum aus dem virtuellen Speicher des gerade berechneten Prozesses an und nutzt dafür die logische Adresse. Diese muss zunächst übersetzt werden in eine physikalische was über die sog. Page Table erfolgt. Hier wird jeder logischen Adresse eine physikalische zugeordnet, also eine Stelle im RAM, die das entsprechende Datum hält. Befindet sich das Datum noch im Massenspeicher, so wird es zunächst dort angefordert und dann die RAM-Adresse zugeordnet. Das Nachschlagen in der Page Table ist sehr zeitaufwändig, da sie häufig selber im RAM gehalten wird. Daher wurde der sog. Translation Lookaside Buffer (TLB) eingeführt, eine direkt in Hardware realisierte Schaltung, welche Übersetzung von logischer zu physikalischer Adresse halten kann (i.d.r. 32 bis 64 Einträge). Erfolgt eine Adressauflösung, so wird das Paar physikalische / logische Adresse in der TLB zwischengespeichert. Erfolgt eine erneute Anforderung der gleichen logischen Adresse, so muss diese nicht über die Page Table übersetzt, sondern kann durch den viel schnelleren TLB aufgelöst werden, vorausgesetzt die Übersetzung wurde in der Zwischenzeit auf Grund von Platzmangel nicht wieder aus dem Speicher verdrängt. Ähnlich dem Cache existieren auch bei einem TLB TLB-Misses und TLB-Hits. Auch hier gibt [2] vertiefende Informationen in Kapitel 10 Virtueller Speicher. 2.3 Modelle Studiert man die beiden obigen Unterabschnitte, so erschließt sich leicht, dass man vor allem die Anzahl der Cache- und TLB-Misses reduzieren möchte. Dass man die Daten einmalig in den Speicher laden muss bzw. eine Adresse übersetzen muss, lässt sich nicht umgehen. Wenn ein Datum jedoch im Speicher bzw. eine Übersetzung in der TLB ist, so sollten diese Einträge frühestens dann verdrängt werden, wenn sie nicht wieder benötigt werden. Somit lässt sich die Anzahl der Misses reduzieren und die Rechengeschwindigkeit erhöhen. U.a. um zu analysieren, wie viele Cache- und TLB-Misses innerhalb eines Programms erfolgen, wurden Modelle entwickelt, welche die wichtigsten Hardware-Eigenschaften in Parameter abbilden, mit denen dann gerechnet werden kann. Eines dieser Modelle ist das sog. Cache Memory Model (CMM), in dem davon ausgegangen wird, dass zwar ein Cache existiert, jedoch keine TLB. Der Cache ist dabei ein virtueller Cache, er kann also logische Adressen verarbeiten. Zwar existieren solche Caches, in der Realität spielen sie jedoch nur eine untergeordnete Rolle, da fast alle Caches physikalische Caches sind und somit eine bereits aufgelöste physikalische Adresse benötigen. Über folgende, häufig bereits oben eingeführte Parameter, verfügt dieses Modell (siehe auch [3], Seite 178): N = Anzahl der Dateneinheiten (z.b. 32-Bit-Integer-Words), welche sortiert, durchsucht,... werden müssen M = Anzahl der Dateneinheiten, welche im Cache gehalten werden können B = Anzahl der Dateneinheiten in einer Cacheline

118 112 a = Assoziativität des Caches Anzahl der Cache-Misses Anzahl der ausgeführten Instruktionen Wie oben bereits erläutert, ist dieses Modell unrealistisch, da TLBs keine Berücksichtigung finden. Das Internel Memory Modell (IMM) ergänzt das CMM um entsprechende Parameter: T = Anzahl der Adressübersetzungen, welche eine TLB halten kann B = Anzahl der Dateneinheiten in einer Page Anzahl der TLB-Misses Bei der Verwendung dieser Modelle muss dabei immer berücksichtigt werden, dass man als Softwareentwickler keinen Einfluss darauf hat, wann welches Datum im TLB oder Cache verdrängt oder gehalten wird. Dies sind Entscheidungen, welche die Hardware intern trifft und von außen nicht steuerbar ist. Im Rahmen des CMM und IMM geht man bzgl. der Verdrängungsstrategie davon aus, dass LRU eingesetzt wird, wie oben bereits beschrieben. 3 Konkretes Anwendungsbeispiel: Sortieren In Kapitel zwei wurden das Cache Memory Modell und Internel Memory Modell vorgestellt. Vor allem letzteres soll nun herangezogen werden, um die Cache- und TLB-Misses von Sortieralgorithmen zu analysieren und zu verbessern. Dies soll beispielhaft an (P)LSB- Radix-Sort und Counting-Sort erfolgen. 3.1 Counting-Sort und LSB-Radix-Sort Algorithmus 1 Couting-Sort-Algorithmus 1: {Count-Phase} 2: for int i = 0; i < k; i++ do 3: C[A[i].Key()]++; 4: end for 5: {Prefix sum-phase} 6: for int i = 0; i < n; i++ do 7: C[i] = C[i] + C[i-1]; 8: end for 9: {Permute-Phase} 10: for int i = k-1; 0 <= i; i-- do 11: Z[C[A[i]]] = A[i]; 12: C[A[i].Key()]--; 13: end for Counting-Sort ist ein stabiles Sortierverfahren, welches ohne direkte Vergleiche der zu sortierenden Schlüssel auskommt (siehe auch [6], Kapitel 8.2). Der Algorithmus unterteilt

119 113 Abbildung 1: Beispielhafter Auszug aus der Permute-Phase des Counting-Sort- Algorithmus sich dabei in drei Phasen: Count-Phase, Prefix sum-phase und Permute-Phase. Während der Count-Phase wird über das Ausgangsarray A gelaufen, das Vorkommen jeder einzelnen Zahl gezählt, indem in einem Zähler-Array C jener Eintrag inkrementiert wird, der zu dem Schlüssel der Datenstruktur im Ausgangsarray gehört: C[A[i].Key()] + +. Die Größe des Zählerarrays richtet sich nach dem größten Schlüssel, der sortiert werden soll. In der Prefix sum-phase wird über das Zählerarray C gelaufen und das Vorkommen der Zahl an der Stelle i aufaddiert auf das Vorkommen der Zahlen, die kleiner sind als i: C[i] = C[i] + C[i 1]. In der Permute-Phase wird das Ausgangsarray von hinten nach vorne durchlaufen (nur so kann Stabilität garantiert werden) und im Zählerarray nachgeschaut, an welche Stelle der im Ausgangsarray aufgefundene Schlüssel in ein extra Zielarray Z geschrieben werden soll: Z[C[A[i].Key()]] = A[i]. Wurde das Datum in das Zielarray kopiert, so wird der entsprechende Eintrag im Zählerarray dekrementiert: C[A[i].Key()]. Wenn k die Anzahl der Keys ist, welche sortiert werden müssen und n die Größe des Zählerarrays, dann beträgt die Laufzeit des Algorithmus O(k + n), denn es muss insgesamt zweimal über das Ausgangsarray (Phase 1 und 3) und einmal über das Zählerarray (Phase 2) gelaufen werden (siehe auch Abbildung 1 und Algorithmus 1). Das Problem des Counting-Sort-Algorithmus ist, dass die größte Zahl, welche sortiert werden soll im Vornherein bekannt sein muss und die Anzahl der Zählerarray-Einträge von dieser Zahl abhängt. Möchte man z.b. Zahlen von 0 bis 1000 Sortieren, wird ein Array mit 1001 Einträgen benötigt. Least Significant Bit (LSB)-Radix-Sort bietet eine Lösung für dieses Problem (siehe [6], Kapitel 8.3). Hierbei werden die zu sortierenden Schlüssel Stelle für Stelle von hinten nach vorne durchgegangen und nach der Ziffer der gerade betrachteten Stelle sortiert. Zunächst werden also alle Zahlen nach der letzten Stelle, dann nach der vorletzten usw. in die richtige Reihenfolge gebracht. Man muss somit nur noch wissen, wie viele Stellen die größte Zahl hat, die sortiert werden soll und benötigt ein Zählerarray, welches (in diesem Beispiel) maximal 10 Einträge aufweisen muss. Es sei an dieser Stelle angemerkt, dass das Alphabet, welches man sortiert, natürlich irrelevant ist, nur beispielhaft sei dies hier anhand von Zahlen und den Ziffern 0 bis 9 dargestellt. Für

120 114 Abbildung 2: 32-Bit-Integer-Word mit eingezeichneter 8 Bit Radix r das Sortieren der Zahlen nach Stellen bietet sich für jede Iteration jeweils Counting-Sort an. Sei d die Anzahl der Stellen, dann muss d-mal Counting-Sort aufgerufen werden und die resultierende Laufzeit ist O(d(k + n)). 3.2 Bestimmung einer Radix für LSB-Radix-Sort Es sei folgende Situation gegeben: N 32-Bit-Integer-Zahlen sollen sortiert werden. Die Fragestellung ist nun, in wie viele Stellen diese 32-Bit-Zahl unterteilt wird, um die Sortierung dann mittels LSB-Radix-Sort unter Verwendung von Counting-Sort durchzuführen. Wird zum Beispiel eine Radix r von 8 (eine Stelle hat 8 Bit) gewählt, dann benötigt das Counting-Array 2 8 = 256 Einträge und es muss für jeden der 32/8 = 4 Stellen jeweils ein Counting-Sort-Durchgang erfolgen. Wählt man hingegen r = 16, so muss Counting-Sort nur zwei mal aufgerufen werden, es ist jedoch ein Zählerarray von 2 16 = Einträgen nötig. [3], Seite 190f. beschreibt eine Vorgehensweise, welche primär auf TLB-Misses aufbaut und eine Analyse für die TLB-Miss-Wahrscheinlichkeit aufstellt. Dabei wird davon ausgegangen, dass es in der Permute-Phase des Counting-Sort-Algorithmus zu den meisten TLB-Misses kommt. Diese Annahme ist realistisch, wenn man davon ausgeht, dass das Zählerarray gänzlich in eine Page passt. Während der Couting-Phase besteht das working set 1 aus dem Ausgangsarray, welches Seite für Seite geladen wird (sequentieller Zugriff). Auf das Zählerarray hingegen wird randomisiert zugegriffen, so dass dieses komplett über die TLB referenziert werden muss. Dies benötigt jedoch auch nur einen Seiteneintrag in der TLB, da ja davon ausgegangen wird, dass das komplette Array in eine Seite passt. Somit werden in der Counting-Phase nur zwei TLB-Einträge benötigt, in der Prefix sum-phase sogar nur eine Seite, nämlich die des Couting-Arrays selber. Während der Permute-Phase wird{ hingegen eine Seite des Ausgangsarrays und die des Zählerarrays bentötigt, sowie min B N, 2 r} Seiten für das Zielarray. Letztere Berechnung kommt dadurch zustande, dass entweder das komplette Zielarray (auf welches ja ebenfalls randomisiert zugegriffen wird) in den Speicher geladen wird, welches B N Pages benötigt oder aber für jeden Array- Eintrag mindestens eine freie Page vorsieht, in der das Datum abgelegt werden kann (2 r Seiten). Ist eine Seite voll, so kann diese Referenz aus dem Speicher entfernt und durch eine neue ersetzt werden. Auf die verdrängte Seite muss garantiert nicht wieder zugegriffen werden, da diese ja voll ist und daher keine weiteren Daten aufnehmen kann. Somit ist die 1 working set = Anzahl der Seiten, welche in einem bestimmten Zeitraum benötigt werden; alternative Erklärung: Anzahl der nötigen Seiteneinträge in einer TLB, so dass keine Seite verdrängt werden muss, die später noch einmal benötigt wird (capacity miss)

121 115 Permute-Phase tatsächlich der Flaschenhals des Algorithmus bzgl. der TLB-Misses. Geht man davon aus, dass 2 r Seiten für das Zielarray benötigt werden, so ist die Wahrscheinlichkeit für einen TLB-Hit in der Permute-Phase bei Zugriff auf das Zielarray (T 2) 2. T 2 r ergibt sich aus der Tatsache, dass von T möglichen TLB-Einträgen je eine Seite bereits für das Ausgangs-Array und das Zähler-Array reserviert sind. Die Wahrscheinlichkeit für (T 2) einen TLB-Miss ist somit 1 2 = 2r (T 2) r 2. Für r = 6 ergibt sich somit eine Miss- r Wahrscheinlichkeit von 1/32 und für r = 7 von 1/2. Tests auf einer UltraSparc-II-Maschine haben tatsächlich deutlich längere Ausführungszeiten für r = 7 als für r = 6 festgestellt. Es sei an dieser Stelle darauf hingewiesen, dass hier nur TLB-Betrachtungen eingeflossen sind. Berücksichtigt man auch Cache-Strukturen, so erzielt man mit r = 5 gute Erfolge, was sich experimentell ebenfalls bestätigen ließ. 3.3 PLSB-Radix-Sort Presorted LSB-Radix-Sort (nach [4]) stellt eine Optimierung des LSB-Radix-Sort-Verfahrens dar, wobei die zu sortierende Zahlenfolge in kleinere Segmente zerlegt wird und diese Segmente zunächst jeweils sortiert werden. Danach wird dann die gesamte segmentweise sortierte Eingabe sortiert, wie Algorithmus 2 zeigt. Der Algorithmus verfügt über Algorithmus 2 PLSB-Radix-Sort nach Rahman und Raman [sic] 1: initialise GlobalCount 2: for each local sort i do 3: initialise LocalCount 4: count keys in segment i of Data1 5: accumulate values in GlobalCount 6: prefix sum LocalCount 7: permute from segment i of Data1 to segment i of Data2 8: end for 9: prefix sum GlobalCount 10: permute globally from Data2 to Data1 zwei Arrays Data1 und Data2 (wobei Data1 das Ausgangsarray ist) und zwei Zählerarrays LocalCount und GlobalCount. Zunächst wird jedes Segment i von Data1 nach Segment i von Data2 sortiert, wobei auch das globale Zählerarray entsprechend der Anzahl der Keys in dem Segment i erhöht wird. Abschließend wird in Zeile 9 und 10 das gesamte Array sortiert von Data2 nach Data1, so dass in Data1 die sortierte Folge gespeichert ist. Anhand dieses Beispiels soll nun eine Cache-Miss-Analyse vorgenommen werden. Auch eine TLB-Miss-Analyse ist möglich, welche der Cache-Miss-Analyse jedoch sehr ähnelt und in [4] nachgeschlagen werden kann. Für die nun folgenden Betrachtungen gilt die folgende Voraussetzung (im IMM-Modell): Die Anzahl der Dateneinheiten s in einem Segment sei s= BC 2. Es gelte weiterhin m = 2r, γ= s m, γ 2, C = a S, a = 4. Schritt (Zeile) 1 und 9 verursachen demnach jeweils m B Cache- Misses, denn die m Zählerarray-Elemente benötigen m B Cachelines (es sei hier der Einfachheit halber davon ausgegangen, dass das Zählerarray optimal im Speicher liegt und somit eine Addition von +1 entfallen kann (siehe hierzu auch beispielhaft Abbildung 3)). Die for- Schleife wird N s mal durchlaufen, somit sind für Schritt 3 maximal N s m B = N γ B 0, 5 N B

122 116 Abbildung 3: Die k Elemente mit dem Wert i des j-ten Segments passen zwar in eine Cacheline liegen jedoch ungünstig und benötigen daher zwei Cachelines. Cache-Misses nötig. Da 2 s m gilt folgt, dass m s 2 = BC 4 = B a S 4 = S B und somit ist maximal eine Cacheline in jedem Set des Caches mit dem LocalCount-Array belegt. In Schritt vier muss das Segment i sequentiell in den Speicher geladen werden, wofür nach bekanntem Schema gilt N s s B. Durch die jeweiligen Segmente werden maximal zwei Cachelines je Set belegt, denn s = BC 2 = B a S 2 = 2 B S. Bei Schritt fünf kann man theoretisch davon ausgehen, dass das GlobalCount bereits im Speicher ist, für den Worst-Case haben die Autoren jedoch trotzdem N s m B 0, 5 N B Cache-Misses hinzuaddiert. Schritt 6 benötigt keine weiteren Cache-Misses, da das LocalCount-Array vorher bereits in den Speicher geladen wurde und durch die Nutzung in Schritt fünf bestimmt nicht LRU ist. In Schritt 7 wird davon ausgegangen, dass nur das LocalCount-Array im Speicher ist. Somit müssen das Data1-Array, auf welches sequentiell und das Data2-Array auf welches randomisiert zugegriffen wird, in den Speicher geladen werden (2 s B Cache-Misses). Positiv wäre es, wenn das Data2-Array (maximal 2 Cachelines je Set) und das Loacalcount-Array (maximal eine Cacheline je Set) im Speicher verbleiben würden, denn auf diese wird randomisiert zugegriffen. Da dies jedoch nicht garantiert werden kann, wird vom Worst Case ausgegangen und dies wäre die Verdrängung aller 3 Cachelines und das spätere Nachladen ( dieser ) Inhalte, also 3 S = 3 a S 4 = 3C 4 Cache-Misses. Insgesamt sind somit N s 2 s B + 3 C 4 3, 5 N B Misses für Schritt 7 anzurechnen. Für Schritt 10 wird die Variable k (i,j) eingeführt, welche aussagt, dass der Schlüssel i im j-ten Segment genau k-mal vorkommt. Möchte man diese k(i,j) k Schlüssel von Data2 nach Data1 permutieren, so benötigt man jeweils B + 1 Cachelines, wobei das +1 aus der Tatsache heraus resultiert, dass die k Schlüssel nicht exakt zu Beginn einer Cachline anfangen müssen, sondern möglicher weise sogar an ihrem Ende (siehe auch Abbildung 3). Des weiteren wird genau eine Cacheline des GlobalCount-Arrays benötigt, welche nämlich den Eintrag i hält. Somit ( resultiert als gesamter Cacheline-Bedarf k(i,j) ) für die Permutation eines bestimmten k (i,j) : 2 B k(i,j) B + 5. Addiert man die Kosten für die einzelnen Schritte auf, so stellt man fest, dass die Anzahl der Cache-Misses kleiner ist als 7, 5 N B +5 N γ +2 m B. Da jedoch gilt S B N (hier wird von einem sehr großen N ausgegangen) und hieraus folgt m N, kann für das Gesamtergebnis auch geschrieben werden: 7, 5 N B + 5 N γ + 2 N B. Wenn γ = Ω(B), dann ist die Anzahl ( ) der Cache-Misses gerade O N B. Man kann ebenfalls zeigen, dass unter der Bedingung ( ) γ = Ω(B) auch die Anzahl der TLB-Misses gerade O N B ist. Experimente auf einer UltraSparc-II-Maschine haben gezeigt, dass der PLSB-Radix- Sort-Algorithmus tatsächlich eine deutlich bessere Geschwindigkeit aufweist, als der nicht optimierte LSB-Radix-Sort. So benötigt LSB-Radix-Sort für das Sortieren von 32 Million 32-Bit-Unsigned-Integer-Zahlen mit Radix 11 31,71 Sekunden, während PLSB-Radix-Sort mit Radix 11 nur 17,03 Sekunden benötigte.

123 117 Abbildung 4: Einfache Trie-Struktur. Fett markierte Knoten sind akzeptierte Endzustände. 4 Konkretes Anwendungsbeispiel: Suchen Während im vorhergehenden Kapitel das Sortieren im Mittelpunkt stand, soll hier ein Algorithmus nach Acharya, Zhu und Shen vorgestellt [1] werden, welcher die Speicherung der Knoten in Trie-Suchbäumen optimiert. Abbildung 4 zeigt beispielhaft eine solche Trie-Struktur. Soll herausgefunden werden, ob das Wort Baum in einem Wörterbuch vorkommt, so muss Buchstabe für Buchstabe überprüft werden, ob ein entsprechender Link aus dem gerade betrachteten Knoten hinausführt. So beginnt das gesuchte Wort mit B und der Startknoten verfügt auch über eine Kante mit dem Label B, also gelangt man zu dem Knoten mit der Beschriftung B. Ist man bei dem Buchstaben u des Wortes Baum angekommen, so müssen alle drei hinausführenden Kanten des Knotens Ba überprüft werden, ob sie mit u gelabelt sind. Ziel ist es, genau diese Suche innerhalb der Knoten zu optimieren Datenstrukturen Acharya, Zhu und Shen schlagen in ihrem Paper vor, drei verschiedene Datenstrukturen zu nutzen. Ist die Anzahl der ausgehenden Links eines Knotens klein, so sollen sog. Partitioned-Arrays verwendet werden, welche aus je zwei Cachlines bestehen. In eine Cachline werden die Schlüssel in der anderen die korrespondierenden Pointer auf den zugehörigen Knoten der nächsten Ebene des Tries hinterlegt. Partitioned-Arrays sind fundamental für alle drei Datenstrukturen, welche die Autoren in ihrem adaptiven Algorithmus verwendet haben und werden immer wieder eingesetzt. Für die Trennung von Keys und Pointer in separaten Arrays spricht die Tatsache, dass alle Keys in einer Cachline gespeichert sind und somit erst diese Cacheline auf das Vorhandensein eines gesuchten Keys überprüft werden kann. Ist ein Key nicht vorhanden, müssen auch keine Pointer (und somit auch nicht die zweite Cacheline) geladen werden, was die Anzahl der geladenen Lines reduziert ([1], Seite 298):

124 118 Abbildung 5: Links: Partitioned-Array mit beispielhafter Belegung für den Knoten Ba ; Rechts: Hashtable mit Partitioned-Array als Overflow-Chain This reduces the number of links loaded and avoids loading any links for unsuccessful searches. Bei heutigen Rechnersystemen ist es des weiteren so, dass nicht gewartet werden muss, bis die gesamte Cacheline geladen ist, sondern nur das Laden der ersten Speicheradresse der Cacheline abgewartet werden muss. Während auf diese Daten bereits zugegriffen werden kann, wird im Hintergrund der Rest der Cacheline übertragen. Somit ist es auch aus diesem Aspekt sinnvoll... that the data structures should be designed so as to pack as many elements in a cache line as possible. ([1], Seite 298) Acharya, Zhu und Shen schlagen zwei Algorithmen sowohl für große als auch für kleine Alphabete vor. Die nun folgende Beschreibung erläutert jenen für große Alphabete, für die Abweichungen innerhalb des Algorithmus für kleine Alphabete sei an dieser Stelle auf das Paper verwiesen. Reicht ein Partitioned-Array zur Speicherung aller Keys nicht mehr aus, so wird in eine B-Tree-Struktur umgeschaltet. Experimente haben ergeben dass die maximale Tiefe eines solchen B-Trees gerade 4 sein sollte. Der Fanout der einzelnen Knoten des B-Trees ist begrenzt durch die Anzahl der Keys, welche in einer Cacheline hinterlegt werden können. Auch hier werden die einzelnen Knoten des Trees durch Partitioned-Arrays realisiert. Übersteigt der B-Tree die Tiefe 4, so wird in eine Hashtable umgeschaltet, wobei die Overflow-Chain für den Konflikt-Fall ebenfalls in einem Partitioned-Array realisiert wird. 4.2 Experimentelle Ergebnisse Die Autoren haben verschiedene Tests durchgeführt, um die Leistungsfähigkeit ihres Algorithmus zu beweisen. So nahmen sie ein englisches Standard-Wörterbuch (Webster s Unabridged Dictionary) und ließen hierin nach allen Wörtern suchen, welche in Melville s Moby Dick auftauchen. Der Ternary-Algorithmus, welcher eine sehr effiziente

125 119 Abbildung 6: Adaptiver Algorithmus im Vergleich zu einfachen B-Tree-Strukturen (aus: [1], Seite 304) Abbildung 7: Adaptiver Algorithmus im Vergleich zu einfachen Hashtable-Strukturen (aus: [1], Seite 304) Implementierung der Trie-Struktur darstellt (siehe [5]), benötigte auf einer Sun-Ultrasparc- II-Maschine für die Suche ca. 2,25 Sekunden, während der hier vorgestellte adaptive Algorithmus gerade etwas mehr als 0,5 Sekunde brauchte. Für einen weiteren Versuch nutzten sie ein Beispiel aus der Welt des Dataminings. Sie stellten einen virtuellen Laden auf und wollten wissen, welche Waren oftmals zusammen gekauft werden. Hierfür wurden tausende von Einkaufszetteln erstellt mit durchschnittlich 4 jedoch maximal 10 Waren. Interessant ist hierbei das sehr große Alphabet: Da der Laden Waren führt hat das Alphabet auch eine Kardinalität von , während das Wörterbuchbeispiel gerade mal ein Alphabet mit ca. 60 Zeichen hatte (Groß- und Kleinschreibung, Sonderzeichen). Somit ist hier sehr wahrscheinlich, dass viele Hashtables zum Einsatz kommen, die in dem Wörterbuchbeispiel aus verständlichen Gründen kaum eine Rolle gespielt haben. Die Autoren haben daher getestet, wie lange das Datamining gedauert hat, wenn nur B-Trees innerhalb der Trie-Knoten eingesetzt werden bzw. wenn zwischen B-Trees und Hashtables umgeschaltet wird. Abbildung 6 zeigt die Resultate für , , und Einkaufszettel. Deutlich ist zu erkennen welche Geschwindigkeitsvorteile der adaptive Algorithmus gegenüber den reinen B-Trees hat. Das gleiche Experiment wurde auch im Vergleich zu Hashtables anstatt B-Trees durchgeführt. Auch hier ist der adaptive Algorithmus noch besser, wie Grafik 7 zeigt, auch wenn der Geschwindigkeitsvorteil längst nicht so eindeutig ist, wie das noch im Vergleich zu B-Trees der Fall war.

126 120 5 Zusammenfassung Um die Zugriffs- und Übersetzungszeiten zu reduzieren, wurden Caches und TLBs eingeführt. Diese können ihre Leistungsfähigkeit jedoch nur zeigen, wenn auch die Cache- bzw. TLB-Misses reduziert werden. Im Falle eines TLB-Miss muss die logische Adresse gänzlich neu übersetzt werden, wodurch der TLB keinen zeitlichen Gewinn bringen würde. Auch im Falle eines Cache-Miss müsste die CPU warten bis der Cache die Daten von Caches tieferer Ebene oder gar vom RAM selber bereit gestellt bekommt. Modelle helfen bei der Bewertung eines Algorithmus bzgl. der Hits und Misses und abstrahieren dafür die vorhandene Hardware. Das Cache Memory Modell berücksichtigt dabei TLB-Architekturen nicht, was jedoch unrealistisch ist, da fast jeder Rechner über eine TLB verfügt. Das Internal Memory Modell schließt diese Lücke und beschreibt sowohl Caches als auch TLBs. Rahman und Raman [sic] verwendeten diese Betrachtungsweise um einen effizienteren Radix-Sort-Algorithmus zu entwerfen, den sie PLSB-Radix-Sort nannten. Durch Vorsortieren der Zahlenfolge kann der randomisierte Zugriff auf das Ziel-Array reduziert werden. Acharya, Zhu und Shen hingegen nahmen sich dem Problem der effizienten Suche in Trie-Baum-Knoten an und entwickelten eine Datenstruktur, welche sich der Größe des Fanouts anpasst. Tests ergaben teilweise deutliche Laufzeitvorteile gegenüber dem Ternary- Algorithmus. Literatur [1] Acharya, Zhu, Shen Adaptive Algorithms for Cache-efficient Trie Search, ALENEX 99, Seiten , Springer-Verlag Berlin Heidelberg, 1999 [2] Carter, Nicolaus Computerarchitektur, mitp-verlag / Bonn, 2003 [3] Goos, Hartmanis, van Leeuwen Algorithms for Memory Hierarchies, Springer-Verlag Berlin Heidelberg, 2003 [4] Rahman, Raman Adapting radix sort to the memory hierarchy, ALENEX 00, Seiten , Springer- Verlag Berlin Heidelberg, 2000 [5] Bentley, Sedgewick Fast Algorithms for Sorting and Searching Strings, Proceedings of the eighth annual ACM-SIAM symposium on Discrete algorithms, Seiten: , New Orleans, Louisiana, United States, 1997 [6] Cormen, Leiserson, Rivest, Stein Introduction to Algorithms, Second Edition, The MIT Press, Cambridge, Massachusetts London, England, 2001

127 121 Cacheoptimierungen für Numerische Algorithmen Tobias Berghoff 1 Einleitung Numerische Algorithmen werden in vielen Anwendungsbereichen wie Simulationen, Multimedia oder Kryptographie eingesetzt. In vielen Fällen ist ihre Laufzeit für die Gesamtlaufzeit der Anwendung dominant, weshalb es wichtig ist, sie effizient zu implementieren. Typischerweise arbeiten die Algorithmen auf großen Datenmengen, die im Hauptspeicher abgelegt sind. Auf jedem Element der Datenmenge führt der Prozessor eine schnell zu berechnende Operation aus, die häufig weniger Zeit braucht, als das Lesen eines neuen Datums aus dem Hauptspeicher in den Cache, bzw. die Register. Die Laufzeit dieser Algorithmen wird demnach häufig durch die Datenzugriffe und nicht durch die Rechenoperationen dominiert. Die Speicherhierarchie, die in diesem Text betrachtet werden soll, ist die zwischen Hauptspeicher, Cache und Registern. Es wird beschrieben, wie die Anzahl der Zugriffe auf die tiefer liegenden Schichten der Hierarchie reduziert werden kann, und wie Kenntnis über das Verhalten des Algorithmus ausgenutzt werden kann um den Speicherdurchsatz zu maximieren. 2 Grundlagen Die Funktionsweise von Caches wird im Weiteren als bekannt vorausgesetzt, es wird aber näher auf das ihnen Zugrunde liegende Prinzip der Zugriffslokalität eingegangen. Dabei werden Iterationsräume und Distanzvektoren als Beschreibungsform von Schleifennestern vorgestellt. 2.1 Schleifennester Ein Schleifennest der Tiefe n ist eine Verschachtelung von n Schleifen. In wissenschaftlichen Programmen wird ein Großteil der Laufzeit in solchen Schleifen verbracht. Das macht sie für die Optimierung interessant und viele Optimierungsansätze verschiedenster Zielsetzung basieren auf der Umstrukturierung dieser Schleifennester. Diese Umstrukturierungen stellen wiederum einen signifikanten Teil der so genannten Schleifentransformationen[1] da. Typische Optimierungsziele sind Vektorisierbarkeit, Instruktionsparallelität, Cacheeffizienz aber auch das Eliminieren unnötiger Datenstrukturen. In diesem Text werden zwei Schleifentransformationen auf Schleifennestern und eine auf Schleifenfolgen vorgestellt, wobei das Augenmerk auf der Verbesserung der Lokalität und damit der Cache-Optimierung liegt. Wenn über Schleifennester gesprochen wird, sind meistens perfekte Schleifennester gemeint, d.h. Schleifennester bei denen Zuweisungen nur in der untersten Schleife vorkommen. Das vereinfacht die Behandlung der Nester enorm und viele Schleifennester lassen sich in perfekte Schleifennester umwandeln, was in der Compiler-Literatur beschrieben ist. 2.2 Zugriffslokalität Der CPU-Cache ist im Vergleich zum Hauptspeicher naturgemäß sehr klein. Es können also im Normalfall nicht alle Daten, die ein Algorithmus betrachtet, in ihm Platz finden. Performanzgewinn bringt der

128 122 for(int i=0;i<n;++i) for(int j=0;j<m;++j) a[i][j]=i+j; Abbildung 1: Schleifennest der Tiefe 2 ohne Abhängigkeiten. Cache entsprechend nur, wenn ein vom Algorithmus gelesenes Datum sich bereits im Cache befindet. Damit das der Fall ist, muss die referenzierte Cache-Line durch einen früheren Zugriff in den Cache geholt worden sein. Das kann auf zwei unterschiedliche Arten geschehen: Im ersten Fall wird auf dieselbe Speicherstelle zugegriffen, bevor die Cache-Line wieder verdrängt wurde. So etwas wird als zeitliche Lokalität bezeichnet, da sich die Zugriffe nur dadurch unterscheiden, wann sie durchgeführt wurden. Der zweite Fall ist, dass eine andere Speicherstelle in der selben Cache-Line gelesen wurde und diese seit dem nicht verdrängt wurde. In diesem Fall spricht man von räumlicher Lokalität, da sich die Position der beiden referenzierten Speicherstellen unterscheidet. Möchte man bestimmen welche Zugriffe eines Algorithmus zu Cache-Hits durch Lokalität führen können, muss man Zugriffe finden, die einem der beiden Fälle entsprechen. Es handelt sich entsprechend immer um Paare von Zugriffen, die irgendwann im Laufe des Programms auf dieselbe - oder eine benachbarte - Speicherstelle zugreifen. 2.3 Iterationsräume Möchte man ein Schleifennest systematisch untersuchen, ist die implizite Beschreibung durch den Quellcode nicht immer die beste, da es häufig nicht offensichtlich ist wie die einzelnen Iterationen von einander abhängen. Eine explizite Beschreibung ist durch den Iterationsraum und die Distanzvektoren in diesem Raum gegeben. Dadurch wird das Überprüfen ob Abhängigkeiten verletzt werden erleichtert und automatisierbar. Für ein Schleifennest der Tiefe n ist der Iterationsraum ein konvexer Polyeder in Z n. Jeder Punkt im Polyeder repräsentiert einen Aufruf des innersten Schleifenrumpfs und sein Positionsvektor enthält die Werte der Schleifenzähler zu diesem Zeitpunkt. Beispielhaft betrachte man das Schleifennest der Tiefe 2 in Abbildung 1. Der Iterationsraum ist in diesem Fall ein konvexes Polygon in Z 2, genauer ein Rechteck. Als erste Dimension des Raums verwenden wir den Wertebereich des Schleifenzählers i, also [0, n 1] und als zweiten den des Zählers j, also [0, m 1]. Nach 3 m + 5 Iterationen sind die Schleifenzähler i = 3 und j = 5. Der Positionsvektor im Iterationsraum (Iterationsvektor) ist entsprechend (3, 5). Der Iterationsraum für diese Schleife mit n = 3 und m = 2 ist in Abbildung 2 dargestellt. Bei den Schleifentransformationen wird davon ausgegangen, dass der gesamte Iterationsraum besucht Abbildung 2: Iterationsraum des Schleifennests in Abb. 1

129 123 werden muss, damit der Algorithmus das gewünschte Ergebnis liefert. Entsprechend wird nur die Reihenfolge, in der Punkte besucht werden, geändert nicht aber die Punkte selbst. Ebenso ändern sich die Werte der Schleifenzähler für die Punkte nicht. 2.4 Abhängigkeiten und Distanzvektoren Es ist offensichtlich, dass nicht jede Abarbeitungsreihenfolge des Iterationsraums für jede Schleife gültig ist. Dies ist nur der Fall, wenn keinerlei Abhängigkeiten zwischen den einzelnen Iterationen des Schleifennests bestehen. Im allgemeinen Fall sind aber nicht alle Iterationen unabhängig voneinander, wie das Beispiel in Abbildung 3 zeigt. Hier werden in jeder Iteration Elemente des Arrays gelesen, das auch for(int i=1;i<4;++i) for(int j=1;j<3;++j) a[i][j]=a[i-1][j]+a[i][j-1]; Abbildung 3: Schleifennest der Tiefe 2 mit Abhängigkeiten. als Speicher für das Ergebnis der Berechnung dient. Um zu bestimmen ob Abhängigkeiten vorliegen und zwischen welchen Elementen des Iterationsraums das der Fall ist, müssen Punkte im Iterationsraum gefunden werden, die auf dieselbe Speicherstelle zugreifen. In Programmiersprachen mit Zeigerarithmetik ist es teils praktisch unmöglich alle Abhängigkeiten zu finden, wenn Zeiger in der Schleife genutzt werden. Daher werden hier nur Zugriffe auf disjunkte Arrays behandelt. Weiterhin sind die Ausdrücke zum Berechnen der Indizes eines Zugriffs Linearkombinationen der Schleifenzähler, was die Verwendung von Vektoren zum Beschreiben der Abhängigkeiten ermöglicht. Auf einen Punkt im Iterationsraum angewandt, zeigen diese Vektoren auf die Iterationen, die von diesem abhängen. do i 1 =l 1, u 1... do i d,l d,u d A[f 1 (i 1,..., i d )],..., f m (i 1,..., i d )] = = A[g 1 (i 1,..., i d )],..., g m (i 1,..., i d )] end do... end do Abbildung 4: Allgemeines Schleifennest[1]. Abbildung 4 zeigt ein verallgemeinertes Schleifennest. Es finden sich in der innersten Schleife sowohl Lese- als auch Schreibzugriffe. Die Funktion f i transformiert einen Iterationsvektor in die i-te Arrayindizierung der Schreiboperation. Die Funktion g i macht selbiges für die Leseoperation. Da eine Iteration J nur von einer anderen Iteration I abhängig sein kann, die vor ihr ausgeführt wurde, wird vorher formal durch die Relation definiert. I J p : (i p < j p q < p : i q = j q ) Es wird davon ausgegangen, dass alle Schleifeninkremente positiv sind. Damit bedeutet es nichts anders, als das die Differenz I J lexikographisch positiv ist, d.h. as erste Element des resultieren Vektors, das nicht 0 ist, muss positiv sein, was leicht einzusehen ist. Damit eine Iteration J von der Iteration I abhängt, muss mindestens eine der beiden eine Schreiboperation sein und folgendes gelten: I J p : f p (I) = g p (J)

130 124 Abbildung 5: Arrayzugriffe vor und nach Schleifenpermutation. Aus [4]. I muss also vor J ausgeführt werden und beide müssen dasselbe Arrayelement referenzieren. Wenn es keine derartigen Iterationen gibt, sind die Zugriffe unabhängig. Die Differenz J I ist der Distanzvektor für dieses Paar von Zugriffen. 3 Schleifentransformationen Die Reihenfolge in der die Punkte im Iterationsraum abgearbeitet werden ist entscheidend für die Performanz des Algorithmus. Häufig lassen sich durch geschickt gewählte Reihenfolgen die Hauptspeicherzugriffe sogar asymptotisch verbessern. 3.1 Schleifenpermutation Ein häufiges Problem bei Schleifennestern ist, dass die unterste Schleife nicht der untersten Dimension des bearbeiteten Arrays entspricht. Das bedeutet, dass nebeneinander liegende Speicherzellen nie nacheinander bearbeitet werden. Die räumliche Lokalität ist also denkbar schlecht. Sinnvoll wäre es, wenn das Schleifennest so aufgebaut ist, dass möglichst viele Zugriffe mit geringer Schrittweite erfolgen. In Abbildung 5 ist auf der linken Seite eine ungeschickte Abarbeitungsreihenfolge dargestellt. Das erste Datum wird gelesen und mit ihm eine komplette Cache-Line, grau dargestellt. Im nächsten Schritt wird die Speicherstelle 8 Elemente weiter, also direkt unter der ersten, zusammen mit einer neuen Cache- Line gelesen. Das wird so fortgeführt, bis das untere Ende des Arrays erreicht wird. Sollten alle bisher eingelesenen Cache-Lines im Cache Platz haben, wird dann das zweite Element der zuerst geladenen Cache-Line bearbeitet, usw. Sobald das Array aber groß genug ist, passen alle diese Cache-Lines nicht mehr gleichzeitig in den Cache. Irgendwo während der Abarbeitung der ersten Spalte wird entsprechend die erste Cache-Line verdrängt. Damit tritt nie eine Wiederverwendung des geladenen Cache-Lines auf. Jeder Datenzugriff erfordert das Laden einer kompletten Cache-Line. Dreht man die Reihenfolge der Schleifen um, so dass die innere Schleife die Indizierung in der untersten Dimension des Arrays übernimmt, so werden - wie rechts abgebildet - alle Daten nur einmal geladen. Jeder Cache-Line wird komplett abgearbeitet und die minimale Anzahl von Cache-Lines wird übertragen. Das ist nun nicht mehr abhängig von der Größe des Caches. Schleifenpermutation ist das Vertauschen von Schleifen in einem Schleifennest. Die Distanzvektoren des resultierenden Schleifennests haben ihre Elemente an den Positionen vertauscht, an denen auch die Schleifen vertauscht wurden. Sind die resultierenden Distanzvektoren legal, d.h. lexikographisch positiv, kann die Transformation durchgeführt werden, ohne das Abhängigkeiten verletzt werden.

131 125 Abbildung 6: Iterationsraum mit linearer Abarbeitungsreihenfolge (links) und Z-Kurve (rechts).[4] 3.2 Schleifen Kacheln Man betrachte eine Funktion, die eine beliebig große Matrix transponiert. Für eine n n-matrix könnte die innere Schleife beispielsweise wie in folgt aussehen. for(int i=0;i<n;++i) for(int j=0;j<n;++j) a[i][j]=b[j][i]; Es fällt direkt auf, dass unabhängig von der Reihenfolge der Schleifen einer der Zugriffe mit Schrittweite n erfolgt, während der andere die gewünschte Schrittweite 1 hat. In diesem Fall ist ein permutieren der Schleifen also keine Lösung. Stattdessen wird der Iterationsraum in Unterräume, so genannte Blöcke oder Kacheln, unterteilt, deren Größe nicht von der Größe der Eingabematrix abhängig ist. Diese Kacheln werden möglichst so gewählt, dass alle Daten die zum Abarbeiten eines Blocks benötigt werden gleichzeitig in den Cache passen. Aus dem zweistufigen Schleifennest wird ein neues vierstufiges erzeugt, dessen äußere zwei Schleifen über die Kacheln iterieren während die inneren zwei Schleifen das Innere der Kacheln abdecken. Die resultieren Abarbeitungsreihenfolge ist eine Z-Kurve, wie in Abbildung 6 zu sehen. Als transformiertes Schleifennest ergibt sich for(int ii=0;ii<n;ii+=b) for(int jj=0;jj<n;jj+=b) for(int i=ii;i<min(ii+b,n);++i) for(int j=jj;j<min(jj+b,n);++j) a[i][j]=b[j][i]; Als Blockgröße B wurde hier die Anzahl von Datenelementen gewählt, die in eine Cache-Line passen. Da B nicht notwendigerweise ein Teiler von n sein muss, können die letzen Blöcke einer Zeile oder Spalte teilweise außerhalb der Matrix liegen. Daher sind die inneren beiden Schleifen extra durch die Dimensionen der Matrix begrenzt. Betrachtet man die Cachegröße, die notwendig ist um ein verdrängen von Cache-Lines, die noch nicht vollständig abgearbeitet sind, zu verhindern, wird der Vorteil der Kachelung offensichtlich. Im ursprünglichen Fall wurde immer genau eine Cache-Line für die Matrix in Array a benötigt, wohingegen die Matrix in Array b bis zu n Cache-Lines benötigt, wie bei der Schleifenpermutation besprochen. Nach der Kachelung muss nur noch eine konstante Anzahl von Cache-Lines gehalten werden: a benötigt weiterhin

132 126 nur ein Cache-Line, hier hat sich nichts geändert 1, da zwar Blockweise gearbeitet wird, diese Blöcke aber aus Cache- Lines bestehen die immer komplett abgearbeitet werden. Für Arrays b ist die konstante Anzahl ebenfalls einfach zu sehen. Hier werden immer B Cache-Lines benötigt, da für jede Zeile (Cache-Line) von a eine Spalte von B Elementen aus einer ebenso großen Kachel von b gelesen werden muss. Die Spalten dieser zweiten Kachel sind wieder hintereinander angeordnet, so dass die n-te Zeile der a-kachel die n-ten Elemente aller Zeilen der b-kachel liest. Wenn die a-kachel also abgearbeitet ist, muss auch die b-kachel nie wieder betrachtet werden. Die Anzahl der Speicherzugriffe hat sich also um den Faktor 1/B auf verbessert, wenn der Cache keine vollständige Spalte von Cache-Lines halten kann. Da wie beim Permutieren von Schleifen die Abarbeitungsreihenfolge des Iterationsraums verändert wird, ist wegen möglicher Abhängigkeiten nicht jede Kachelung legal. Prinzipiell dürfen Schleifen immer dann gekachelt werden, wenn sie auch permutiert werden dürfen. 3.3 Schleifenvereinigung Als Beispiel für eine Schleifentransformation die nicht auf einzelnen Schleifennestern arbeitet wird abschließend die Schleifenvereinigung betrachtet. Bei diesem Verfahren wird ein mehrfaches Abarbeiten des Iterationsraums vermieden in dem mehrere Schleifen mit gleichem Iterationsraum zusammengefasst werden. Man betrachte folgendes Beispiel: for(int i=0;i<n;++i) b[i]=a[i]+1.0; for(int i=0;i<n;++i) c[i]=b[i]*4.0; Beide Schleifen haben denselben Iterationsraum und keine Distanzvektoren, also keine Abhängigkeiten in den Schleifen. Dennoch gibt es Abhängigkeiten zwischen den beiden Schleifen. Die zweite Schleife liest die in der ersten Schleife geschriebenen Werte. Diese Abhängigkeit muss erkannt und eingehalten werden, damit die Transformation fehlerfrei durchgeführt werden kann. Das Betrachten der Distanzvektoren hilft hier nicht, da diese nur Abhängigkeiten in den einzelnen Schleifen beschreiben. Bei diesem Beispiel werden die Abhängigkeiten nicht verletzt, solange für jede Iteration zuerst der Schleifenrumpf der ersten Schleife ausgeführt wird. Es ergibt sich also for(int i=0;i<n;++i) { b[i]=a[i]+1.0; c[i]=b[i]*4.0; } Sollte b nur innerhalb dieser Schleife benutzt werden, so bietet sich eine weitere Technik an, die array contraction genannt wird. Dabei wird das Array b durch ein Skalar b s ersetzt, da immer nur ein Wert von b benötigt wird. Dieser kann dann in einem Register gehalten werden, wodurch für b keine Speicher- Zugriffe mehr anfallen. 4 Datenlayout Bei den Schleifentransformationen bestand der Optimierungsansatz darin, dass man geschickter auf die Daten zugreift. Dieser Abschnitt behandelt, wie die Daten abgelegt werden können um die Lokalität und 1 Hier wird davon ausgegangen, dass mit dem ersten Element der Arrays jeweils eine neue Cache-Line beginnt. Das muss nicht so sein, aber anderenfalls wird nur ein konstanter Faktor von weiteren Cache-Lines benötigt.

133 127 damit die Cache-Effizienz zu steigern. Zuerst werden Schleifentransformationen als Arraytransformation umgesetzt. Danach werden Conflict-Misses betrachtet, die sich durch Schleifentransformationen nur schwer in den Griff kriegen lassen. 4.1 Schleifentransformation als Arraytransformation Sowohl Schleifenpermutation als auch Schleifenkachelung lassen sich sowohl durch Umsortierung der Schleifen als auch durch Umsortierung der verwendeten Arrays realisieren 2. Im Fall der Kachelung kann das zu verbessertem Prefetching (Vergleiche Abschnitt 5) und weniger Conflict-Misses führen, da die Arrays wieder linear durchlaufen werden können. Prinzipiell lassen sich viele Schleifentransformationen derart auf Datenräume übertragen. Im Fall der Schleifenpermutation wird das als array transpose bezeichnet, da im Fall eines zweidimensionalen Arrays die Operation dem Transponieren eines Arrays entspricht. Das ist aber nur ein Spezialfall des allgemeinen Kopierens von Daten zum verbessern der Lokalität (data copying). Diese Operationen können entweder statisch oder dynamisch durchgeführt werden. Im statischen Fall liegen die Daten nur noch im umsortierten Fall vor, was sich anbietet wenn alle Zugriffe von der neuen Sortierung profitieren. Dynamisches Kopieren ist immer mit zusätzlichem Aufwand verbunden, was den Performanzgewinn wieder relativieren kann. 4.2 Vermeidung von Conflict-Misses Bisher wurde davon ausgegangen, dass die volle Kapazität des Caches zur Verfügung steht. Bei vollassoziativen Caches ist diese Annahme auch immer richtig, bei den normalerweise eingesetzten mengenassoziativen Caches aber nicht. Hier kann jede Cache-Line nur in einer geringen Anzahl von Speicherstellen im Cache abgelegt werden. Die Gesamtmenge der Speicherstellen die eine Cache-Line aufnehmen können wird Cache-Menge (cache set) genannt. Die einzelnen Speicherstellen in der Menge heißen wiederum Wege (ways). CPU-Caches sind häufig 4- bis 12-Weg mengenassoziativ und der denkbar schlimmste Fall ist, wenn nur wenige Mengen des Caches tatsächlich benutzt werden. Die so verursachten Cache-Misses, ausgelöst nicht durch einen vollen Cache sondern nur durch volle Cache-Mengen, nennt man Conflict-Misses. Um sie zu verhindern muss dafür gesorgt werden, dass die Daten in möglichst viele Cache-Mengen fallen. Gegeben sein folgende Schleife: double a[1024]; double b[1024]; for(int i=0;i<1024;++i) sum+=a[i]+b[i]; Wenn die Hardware ein direkt abbildender Cache (1-Weg mengenassoziativ) ist, dessen Hashfunktion Speicheradressen mit 8192 Byte (1024 mal 8 Byte pro double) Unterschied auf denselben Wert abbildet, so treten hier Conflict-Misses auf. Bei dieser Konfiguration fallen die Cache-Lines von a[i] und b[i] in die gleiche Menge und verdrängen sich gegenseitig. Die gesamte Schleife verursacht entsprechend 2047 Cache-Misses. Trotz prinzipiell extrem hoher räumlicher Lokalität wird kein einziger Cache-Hit erreicht. Man sieht an diesem Beispiel direkt, dass Schleifentransformationen hier nur bedingt helfen. Man kann die Schleife zwar in zwei Schleifen aufteilen, von denen eine dann über a und die andere über b geht 3, allgemein ist diese Lösung aber nicht. 2 Natürlich benötigt eine Arraytransformation häufig auch Änderungen an den Schleifen. 3 Also eine inverse Schleifenvereinigung, Loop Distribution genannt.

134 Inter-Array Padding Da das Auftreten der Cache-Misses abhängig von den Startadressen der Arrays ist, können diese verschoben werden. Man fügt dazu zwischen den beiden Arrays eine Anzahl von Füllbytes (so genannten padding bytes) eine, deren einzige Aufgabe es ist, die Startadresse des zweiten Arrays zu verschieben. Die genaue Anzahl der Füllbytes hängt natürlich von der Hashfunktion des Caches ab. Prinzipiell sieht die Arraydeklaration nun folgendermaßen aus: double a[1024]; char pad[x]; //x bytes padding double b[1024]; Der Nachteil dieser Methode ist, dass sie von der Hashfunktion abhängig ist. Wenn man aber nur Einfluss auf eins der beiden Arrays hat, so ist sie die einzige Lösung Array Merging Eine Methode die für kleine Arrayelemente unabhängig von der Hashfunktion ist, ist das array merging, also das Zusammenfügen von Arrays. Die grundlegende Beobachtung ist, dass im Speicher nah beieinander liegende Daten nicht in dieselbe Cache-Menge fallen werden 4. Die Arrays werden jetzt im Reißverschlussverfahren zu einem einzigen Array zusammengesetzt, so dass Elemente auf die zeitgleich zugegriffen wird, räumlich nebeneinander liegen. Erreicht werden kann das entweder durch ein Array mit um 1 erhöhter Dimension, oder durch ein Array von Datensätzen, falls die Programmiersprache so etwas unterstützt und nicht nur Zeiger auf Heap-Objekte als Arraytypen zulässt. double ab[1024][2]; //Arraydimension erweitert. struct{ double a; double b; } ab[1024]; //Array von Datensätzen. Der Vorteil der Datensätze ist, dass auch Arrays unterschiedlicher Typen auf diese Weise zusammengefügt werden können. In beiden Fällen treten keine Conflict-Misses mehr auf und wenn n Werte vom Typ double in eine Cache-Line passen, treten nur noch maximal 1/n so viele Cache-Misses auf. 4.3 Bandbreitenreduktion Bei jedem Datentransfer zwischen Hauptspeicher und Cache werden ganze Cache-Lines übertragen. Entsprechend ist es wichtig dafür zu sorgen, dass in diesen Cache-Lines möglicht viel benötigte Daten liegen. In vielen Fällen iteriert das Programm über ein Array von Datensätzen, von denen nur Teile für die Berechnung gebraucht werden. Trennt man diese benötigten Daten von den unnötigen Daten lässt sich die Anzahl der zu übertragenden Cache-Lines teils erheblich reduzieren. Ein typisches Beispiel hierfür sind 3D-Anwendungen, bei denen Eckpunkte von Polygonen aus einer Position und einer Reihe von Attributen bestehen. Diese Attribute werden meistens erst in der Grafikkarte benötigt, wohingegen die Positionsdaten auch auf der CPU häufig gebraucht werden, beispielsweise um die Polygone zu sortieren. In diesem Fall ist es sinnvoll die Daten nicht in einem einzelnen Array zu speichern, sondern separate Arrays für Positionsdaten und Attribute zu haben. Ein anderer Ansatz wird bei Algorithmen mit wahlfreiem Zugriff auf die Datenelemente interessant. Hier ist der ideale Fall, dass ein Datensatz in der minimalen Anzahl Cache-Lines liegt, da diese bei 4 Technisch ist es zwar möglich einen solchen Cache zu bauen, es macht aber wenig Sinn.

135 129 struct bad{ struct good{ char a; char a; //3 byte compiler padding; char c; int b; char e; char c; //1 byte compiler padding; //3 byte compiler padding; int b; int d; int d; char e; int f; //3 byte compiler padding; }; //16 bytes int f; }; //24 bytes Abbildung 7: Links, eine durch Compiler Padding vergrößerte Struktur. Rechts, Verkleinerung durch Umsortierung. einem Zugriff alle geladen werden müssen. In diesem Fall vergrößert man das Objekt künstlich, bis seine Größe einem Vielfachen der Cache-Line-Größe entspricht. Danach muss man noch dafür sorgen, dass alle Objekte auch genau so im Speicher positioniert werden, dass sie für den Cache optimal ausgerichtet sind. Bei Objekten deren Größe kleiner oder gleich derer einer Cache-Line ist, wird die Anzahl der zu ladenden Cache-Lines so häufig halbiert Datenausrichtung Viele Prozessoren erfordern, dass mehrbyteige Datentypen im Speicher entsprechend ihrer Größe ausgerichtet sind. Dazu muss die Adresse, an der sich ein Datum der Größe n befindet durch die nächst größere (oder gleichgroße) Zweierpotenz teilbar sein. Einige Prozessoren unterstützen nicht ausgerichtete Daten nicht, bei anderen sind diese Zugriffe nur langsamer. Compiler passen entsprechend die Positionen von Einträgen in Datenstrukturen so an, dass sie für die Architektur optimal liegen. Das lässt sich normalerweise über Compileranweisungen abstellen, führt aber zu langsamerer Ausführungsgeschwindigkeit. Besser ist es, wenn die Einträge so umsortiert werden, dass beim Ausrichten möglichst wenig Speicher verschwendet wird. Dazu sortiert man die Elemente nach ihrer Größe. Der Compiler kann diese Umsortierung im Allgemeinen nicht durchführen, da Daten von Programmteilen benutzt werden können, die von anderen Compilern übersetzt wurden.

136 130 Abbildung 8: Programmablauf ohne Prefetching. CPU und Speichersystem warten abwechselnd[3]. 5 Prefetching Die bisher vorgestellten Methoden versuchen die Anzahl der Cache-Misses zu minimieren. Beim Prefetching hingegen nutzt man Parallelität von CPU und Speichersystem um die Latenzen des Speichersystems zu verdecken. In Abbildung 8 ist ein typischer Programmverlauf mit gekoppelter CPU und Speicher zu sehen. Es ist immer nur eine der beiden Einheiten aktiv, während die andere auf die nächste Anweisung wartet. Die Idee des Prefetching ist es nun, die Caches asynchron mit Daten zu füllen. Das bedeutet, dass die CPU nicht-blockierende Leseanfragen an das Speichersystem stellt, das diese dann versucht zu verarbeitet. Entscheidend hierbei ist, dass CPU nicht auf die Fertigstellung der Anfragen warten muss und somit weiterrechnen kann, während die langsamen Speicherzugriffe erfolgen 5. Damit Prefetching eingesetzt werden kann, muss das Programm in der Lage sein zu bestimmen, welche Daten es in naher Zukunft benötigt. Bei den hier betrachteten numerischen Algorithmen ist das sehr häufig gegeben, da a priori bekannt ist, wie sie den Iterationsraum abarbeiten und ihre Zugriffe durch Linearkombination ihrer Schleifenzähler bestimmt sind. 5.1 Hardware Prefetching Das am weitesten verbreitete Zugriffsmuster bei häufigen Arrayzugriffen sind Zugriffe mit fester Schrittweite (constant stride). Viele CPUs erkennen derartige Zugriffsmuster und beginnen von sich aus Daten im Voraus zu laden. Dieses Verhalten hat den Vorteil, dass in diesem Standartfall der Zugriff auf den Cache ohne Zutun des Programmierers oder des Compilers verbessert wird. Andererseits werden zu viele Daten vorgeladen, wenn die CPU nicht bestimmen kann, wann die Schleife terminiert. 5.2 Software Prefetching Programmierer haben im Regelfall eine sehr genaue Vorstellung davon, wann welche Daten von ihrem Programm benötigt werden. Genau dieses Wissen macht man sich beim Software Prefetching zu Nutzen, indem man im Programm codiert, wann welche Daten geladen werden sollen. Diese Aufgabe kann auch vom Compiler übernommen werden[6], dessen Wissen über den Algorithmus aber naturgemäß geringer ist. Solange im Voraus bekannt ist, welche Speicherstellen geladen werden müssen, hat man nun 5 Eine weitere wichtige Eigenschaft ist, dass diese Anfragen keine Schutzverletzungen verursachen können. So muss das Prefetching nicht vollständig konservativ sein.

137 131 die Möglichkeit die Latenzen des Speichersystems zu verstecken. Zu beachten hierbei ist, dass Prefetchinstruktionen reguläre Speicherzugriffe sind und als solche nicht nur über den Bus übertragen werden müssen, sonder von der CPU bearbeitet werden. Jede dieser Instruktionen erfordert eine Adressberechnung, einen instruction slot in der CPU, Platz im Instruktionscache, usw. Es ist also nicht zu empfehlen so viel wie möglich vorzuladen, da so die CPU wieder ausgebremst wird. Ein weiterer wichtiger Aspekt ist, dass durch die Größenbegrenzung des Caches ein zu aggressives Vorladen die aktuell benötigten (oder andere bereits Vorgeladene) Daten verdrängen und so weitere Cache-Misses verursachen kann. Andererseits ist es optimal, wenn ein Datum bereits vollständig im Cache zu finden ist, wenn es gebraucht wird. Es gilt also zwei Werte zu berechnen: Die Anzahl der Datensätze die für einen optimalen Betrieb vorgeladen werden sollen und die maximale Anzahl der Datensätze, die in den Cache passen. Letzteres ist einfach, sobald die Größen von Cache und Datensätzen bekannt sind. Die optimale Vorladedistanz wird im Folgenden berechnet. 5.3 Prefech Scheduling Distance In erster Nährung ist es sehr einfach zu bestimmen, wie viele Iterationen im Voraus Daten geladen werden sollten 6. Es handelt sich dabei um die für das Prefetching benötigte Zeit dividiert durch die Rechenzeit einer Iteration. Sei N pref die Anzahl der Cache-Lines die in einer Iteration geladen werden müssen, also die Anzahl der benötigten Cache-Lines einer Iteration abzüglich der bereits im Cache vorhandenen. Je nach Architektur müssen unter Umständen eine Menge von Cache-Lines aus dem Cache in eine tiefere Hierarchie kopiert werden, entweder weil sie geändert wurden, oder weil ein Victim-Cache eingesetzt wird. Die Anzahl dieser zu speichernden Cache-Lines sei N st. Wenn das Übertragen einer Cache-Line C transfer Prozessorzyklen in Anspruch nimmt, ist der Gesamtaufwand für das Übertragen der Daten C transfer (N pref + N st ). Dazu kommen noch die Kosten für das Finden der Speicherstelle (z.b. TLB-Misses) und möglicherweise hardwarespezifische Kosten, zusammengefasst in C lookup, was die Gesamtkosten für den Datentransfer einer Iteration ergibt. Auf Seiten der Ausführungseinheit sind die Prozessorzyklen pro Iteration durch die Instruktionen (inklusive Prefetchinstruktionen) N inst und die hardwareabhängigen CP I, den Zyklen pro Instruktion, gegeben. Es ergibt sich daraus folgende Formel[3]. psd = C lookup + C transfer (N pref + N st ) CP I N inst Auch mit perfektem Prefetching werden nicht CPU und Speichersystem mit Volllast arbeiten. Das ist aber auch nicht das Ziel, sondern dass eine der beiden Komponenten nicht warten muss. Das Programm ist also entweder CPU- oder Speicherlimitiert, aber es verschwendet keine Zeit mehr durch unnötige Kopplung der Subsysteme. 6 Die hier vorgestellte Formel geht davon aus, dass das Programm exklusiven Zugriff auf CPU und Speichersystem hat. In einem System mit mehreren Hardware-Threads kann ein anderer Thread das Speichersystem blockieren und die Abschätzung verfälschen.

138 132 6 Anwendungsbeispiel Zwei sehr weit verbreitete Bibliotheken für lineare Algebra sind BLAS und LAPACK. Elementare Matrizenund Vektoroperationen werden dabei von BLAS bereitgestellt, während LAPACK z.b. Algorithmen für das Lösen linearer Gleichungssysteme oder das Berechnen von Eigenwerten enthält. Die LAPACK Algorithmen basieren dabei auf BLAS und versuchen möglichst viel ihrer Berechnung durch BLAS-Aufrufe durchzuführen. Daraus resultiert, dass Optimierungen der BLAS direkt die Performanz des LAPACK beeinflussen. Im Weiteren werden einige der vorgestellten Techniken eingesetzt, um eine so genannte Level 2 BLAS Operation, eine Vektor-Matrizen-Operation, zu optimieren. Der Algorithmus ist im Verlauf des ATLAS-Projekts[7] entstanden. Ziel dieses Projekts ist es automatisch optimierte plattformspezifische BLAS-Implementierungen zu erzeugen. Diese werden im Allgemeinen nicht mit handoptimierten Implementierungen mithalten können, aber für recht viele Plattformen sind solche BLAS-Implementierungen nicht verfügbar. 6.1 Vektor-Matrix-Multiplikation Als Anwendungsbeispiel wird eine Vektor-Matrix-Multiplikation von der Form y Ax + y betrachtet, wobei A eine n n-matrix ist und x, y Vektoren der Größe n. Explizit hat die Berechnung Form y i n A i,j x j + y i, 1 i n j=1 In C implementiert sieht der Algorithmus dann folgendermaßen aus: for(int i=0;i<n;++i) for(int j=0;j<n;++j) y[i]+=a[i][j]*x[j] Der Algorithmus muss zum Berechnen eines y i den gesamten n-vektor x, sowie die n-elementige Zeile A i lesen und y i n mal lesen und schreiben. Da y insgesamt n Elemente hat, ergeben sich daraus n 2 Leseoperationen auf A und x und n 2 Lese- und Schreiboperationen auf y. Prinzipiell lässt sich die Anzahl der Zugriffe nicht ändern, wohl aber die Anzahl der Zugriffe auf den Hauptspeicher, speziell durch Kachelungsverfahren. Einer der beiden Vektorzugriffe (aber nicht beide) kann von n 2 zu n reduziert werden, indem das aktuelle Element in einem Register gespeichert wird. Da y sowohl geschrieben als auch gelesen wird, wird es hier für die Verbesserung ausgewählt. Spezielle Verhaltensweise von Cache- Architekturen führen in der Praxis manchmal dazu, dass x eine bessere Wahl ist. Die Schleife ist jetzt leicht verändert: register float r; for(int i=0;i<n;++i) { r=y[i]; for(int j=0;j<n;++j) r+=a[i][j]*x[j] y[i]=r; } Wenn k Register zur Verfügung stehen, können entsprechend k innere Schleifen gleichzeitig ausgewertet werden und damit die Anzahl der Zugriffe auf x um 1/k reduziert werden. Diese Verbesserung wird auch strip mining genannt und wird ebenfalls eingesetzt um für Vektorprozessoren zu optimieren. Während

139 133 bei k = 1 nur eine register spilling, also ein Kopieren eines Zwischenwerts aus einem Register in den Speicher, vermieden wird, ist die Methode für k > 1 ein register blocking, eine Register Kachelung (vergleiche Abschnitt 3.2). Der Vektor wird in Kachel der Größe k 1 aufgeteilt und bearbeitet. Um die Anzahl der Zugriffe auf x von O(n 2 ) auf O(n) zu reduzieren, wird eine Schleifenkachelung durchgeführt wie in Abschnitt 3.2 beschrieben. Um die Blockgröße zu erhalten muss der Speicherbedarf pro Iteration untersucht werden. Da jedes Mal k Elemente von y aktualisiert werden, müssen k n Elemente von A sowie der vollständige Vektor x gehalten werden. Zusammen mit den zu aktualisierenden Elementen ergibt sich ein Speicherverbrauch von etwa k + n + kn. Gesucht ist also die größte Partitionierung p n von x, so dass für eine L1-Cachegröße S gilt S k + p + kp. Die hier angegebene Formel für den Speicherverbrauch ist offensichtlich reichlich optimistisch. In der Praxis wird mehr Speicher pro Iteration benötigt werden und durch Cache-Konflikte wird die Ausnutzung nicht perfekt sein. Des Weiteren wird der Algorithmus nicht notwendigerweise exklusiven Zugriff auf den Cache haben. Diese Dinge müssen aber von System zu System untersucht werden und sind nicht Teil dieser Betrachtung. Die Kachelung von x unterteilt das n n große Problem in n/p Unterprobleme der Größe n p. Das bedeutet, dass jeweils ein n p großer Teilbereich von a mit einem p großen Abschnitt von x verrechnet wird. Dadurch muss y nun wieder häufiger geladen werden, nämlich n/p n mal. Die Zugriffe auf n sind damit streng genommen wieder quadratisch, aber in der Praxis ist p normalerweise zwischen 300 und 1500, womit nur wenig Kacheln benötigt werden. Ist p = 1 ist keine Kachelung von x notwendig und der Algorithmus reduziert sich auf den nicht gekachelten Fall. Die Finale Version benötigt damit n/p n Lese- und Schreibzugriffe auf y, n Lesezugriffe auf x und n 2 Lesezugriffe auf A. int n_p=ceil(n/p); for(int ii=0;ii<n;ii+=p) { for(int i=0;i<n;++i) { r=y[i]; for(int j=ii;j<min(n,ii+p);++j) r+=a[i][j]*x[j] y[i]=r; } } Bei einem (relativ) unwissenschaftlichen Test 7 konnte das verbesserte Cacheverhalten zwar bestätigt werden, Geschwindigkeitsvorteile waren aber nur schwer zu erreichen. Die Laufzeiten der Algorithmen war etwa vergleichbar, wenn die Anzahl der Cache-Misses des naiven Algorithmus fast dem doppelten derer des hier vorgestellten Algorithmus entsprachen. Beide Algorithmen wurden vom Compiler optimiert, wobei für den naiven Algorithmus ein Ausrollen von 8 Iterationen der untersten Schleife automatisch durchgeführt wurde. Auf einem Athlon64 mit 64KB L1-Datencache und 512KB L2-Cache war das etwa bei n = gegeben, wobei die Matrix dann natürlich nicht länger quadratisch war. Der gekachelte Algorithmus verursachte zu diesem Zeitpunkt doppelt so viele branch mispredictions, die jeweils mindestens 10 Wartezyklen verursacht haben. Das alleine kann aber kein Grund für das gemessene Verhalten sein. Wenn man nur die L1-Cache-Misses betrachtet, die der gekachelte Algorithmus einspart, dann verliert man nur etwa 10% der Zeit durch die falsche branch prediction. 7 Cache-Misses und andere CPU-Ereignisse wurden mit Hilfe der CPU performance counter direkt gemessen. Da diese aber Interrupts auslösen um den Profiler über die Ursache des Ereignisses zu informieren, beeinflussen sie das Ergebnis. Speziell hat das Speichersystem Zeit Daten zu laden, während der Interrupt bearbeitet wird. Es wurde aber darauf geachtet, dass nicht zu häufig Interrupts ausgelöst werden.

140 134 Tatsächlich scheint das Problem mit dem Berechnen der Adressen zu tun zu haben. Die inneren Schleifen der beiden Algorithmen unterscheiden sich nur darin, dass der native Algorithmus sequenziell mit einem Iterator über den Speicher läuft, während der gekachelte Algorithmus parallel mehrere (k) Iteratoren besitzt. Dadurch ist der naive Algorithmus in der Lage der 8 Zugriffe im inneren der Schleife sehr einfach zu adressieren. Er speichert die Startadresse des zu bearbeitenden Blocks in einem Register und addiert dann für jedes Element einen festen Wert bei der Adressberechnung dazu. Da der Athlon64 eine superskalare Architektur besitzt und sich das Startregister während der Iteration nicht verschiebt, können die Adressen parallel berechnet werden, ohne das Abhängigkeiten auftreten. Die Adresseinheiten lesen ein Register aus (das nicht geschrieben wird und daher Latenzfrei gelesen werden kann) und addieren eine Verschiebung. Ein Ausschnitt aus der inneren Schleife des naiven Algorithmus: fld fmul faddp fld fmul faddp fld fmul faddp fld fmul faddp dword ptr [ecx-0ch] dword ptr [eax-0ch] st(1),st dword ptr [ecx-10h] dword ptr [eax-10h] st(1),st dword ptr [ecx-14h] dword ptr [eax-14h] st(1),st dword ptr [ecx-18h] dword ptr [eax-18h] st(1),st Adressierungen sind von der Form dword ptr [register+offset]. Das Register ecx zeigt auf die Spalte von A und eax zeigt auf den Anfang des 8er-Blocks von x. Die Adressierungen des gekachelten Algorithmus sind komplizierter. Beim Zugriff auf x wird jedes Mal durch eine Multiplikation (j mal der Größe eines floats) und eine Addition (Startadresse des Vektors) die entsprechende Position berechnet. fld fmul faddp fld fmul faddp fld fmul faddp dword ptr [eax+4a4e40h] dword ptr [ecx*4+4160fch] st(4),st dword ptr [eax+4ec2e0h] dword ptr [ecx*4+4160fch] st(3),st dword ptr [eax h] dword ptr [ecx*4+4160fch] st(2),st Die CPU muss nun also doppelt so lange auf die Adresse für x i warten. Da jedes Mal auf dieselbe Speicherstelle zugegriffen wird, wird eigentlich nur eine Adressierung benötigt. Reserviert man ein Register für x i, ändert sich das Ergebnis signifikant. Um das Register freizuhaben, wird k von 8 auf 4 reduziert, was keinen Einfluss auf die Geschwindigkeit des Algorithmus hat 8. Zu Anfang der inneren Schleife wird x i in ein Register geladen und dann für die vier Zuweisungen verwendet. Der so optimierte Algorithmus schlägt den naiven bereits bei n = 16 durch seine bessere Instruktionsparallelität, bedingt durch die Verwendung von mehr Registern. 8 Ein Test mit k = 2 erzielte hingegen schlechtere Leistung. Entweder ist der maximale Speicherdurchsatz oder die maximale Instruktionsparallelität (die CPU hat 3 Fließkommaeinheiten) bereits erreicht. Näher untersucht wurde das aber nicht.

141 135 N M S Durchläufe naiv gekachelt verbessert ms 2200 ms 1700ms ms 1120ms 900ms ms 2200ms 1800ms ms 2250ms 1800ms ms 2250ms 1850ms ms 3250ms 3050ms Abbildung 9: Laufzeiten der Algorithmen für y[n] = A[N][M] x[m] + y[n], bei angenommener L1-Cachegröße von S floats. Interessant zu beobachten ist, dass der Geschwindigkeitszuwachs recht konstant ist. Selbst beim Sprung von einer Matrix zu einer Matrix, bei der sowohl x als auch jede Matrizenzeile über 3MB groß ist ändern sich die Verbesserungen nur von 23, 5% zu 44, 5%. 7 Schlussfolgerung Schleifentransformationen sind ein attraktiver Optimierungsansatz, da sie die Qualität des Ergebnisses nicht beeinflussen 9 und teilweise automatisiert werden können. In Programmiersprachen mit Zeigerarithmetik wird das Erkennen aller Abhängigkeiten aber zum Problem, weshalb es häufig dem Programmierer überlassen wird die Transformationsmöglichkeiten zu entdecken und umzusetzen. Ähnlich ist es mit dem Software-Prefetching, das zwar in der Form von Compiler-Anweisungen (intrinsics) seinen Weg in die Praxis gefunden hat, aber wiederum im Allgemeinen nicht automatisiert umgesetzt wird. Zu aggressives Prefetching führt zur Verschwendung von Speicherbandbreite, mehr Cache-Misses und aufgeblähtem Code. Ein zu konservatives Verfahren täuscht unter Umständen Optimiertheit vor, wo nur wenig Verbesserung erzielt wurde. Es läuft darauf hinaus, dass der Compiler versuchen muss zu erkennen, was der Programmierer erzielen wollte und das ist immer problematisch. Da bietet es sich an, das Wissen des Programmierers direkt zu nutzen. Ein weiteres Problem hier ist die Fehlersuche. Ein Source-Level- Debugger ist nur solange von Nutzen, wie der angezeigte Quellcode auch dem ausgeführten Programm entspricht. Nach einigen aggressiven Schleifentransformationen wird dem Programmierer wenig anderes übrig bleiben, als mit der Ausgabe des Compiler - also dem Maschinencode - direkt zu arbeiten. Datenoptimierungen spiegeln Teils die Codetransformationen wieder. Viele Schleifentransformationen können auch als Arraytransformationen durchgeführt werden und häufig macht das auch Sinn. Das Kacheln von Schleifen führt beispielsweise oft zu erhöhtem Auftreten von Conflict-Misses, weswegen die Kacheln dann so umkopiert werden, dass die Speicherzugriffe wieder eine feste Schrittweite haben. Ebenso kann man häufig Software-Prefetching durch Hardware- Prefetching ersetzen, indem man die Reihenfolge der Datenelemente ändert. Das Entfernen von unnötigen Daten wird die Komplexitätsklasse der Hauptspeicherzugriffe nur sehr selten beeinflussen, aber die konstanten Faktoren sind bei der Optimierung nicht zu vernachlässigen. Das Packen, Zerschneiden und Umsortieren der Daten macht einen Algorithmus natürlich nicht unbedingt lesbarer und hier muss abgewogen werden, wie viel Aufwand berechtig ist. Das ist beim Optimieren aber immer so. 9 Das stimmt nur eingeschränkt. Wenn die Genauigkeit der Register größer ist als die der Speicherstellen, kann beispielsweise das Kacheln des Iterationsraums zu reduzierter Genauigkeit führen. Eine Architektur mit diesen Eigenschaften ist Intels x87 Fließkommaarchitektur[2].

142 136 Literatur [1] David F. Bacon, Susan L. Graham and Oliver J. Sharp Compiler Transformations for High- Performance Computing ACM Computing Surveys, 26(4): , [2] Intel Corp IA-32 Intel R Architecture Software Developer s Manual, Volume 1: Basic Architecture, Section 8.1.2, June [3] Intel Corp. IA-32 Intel R Architecture Optimization Reference Manual, Appendix E, June 2006 [4] Markus Kowarschik, Christian Weiß An Overview of Cache Optimization Techniques and Cache- Aware Numerical Algorithms Algorithms for Memory Hierarchies, LNCS 2625, pp , [5] Monica S. Lam, Edward E. Rothberg and Michael E. Wolf The cache performance and optimizations of blocked algorithms ACM SIGPLAN Notices, 26(4):63-74, 1991 [6] Todd C. Mowry Tolerating Latency through Software-Controlled Data Prefetching Dissertation, Stanford University, [7] R. Clint Whaley, Antoine Petitet and Jack J. Dongarra Automated Empirical Optimizations of Software and the ATLAS project Parallel Computing 27(1-2):3-35, [8] Micheal E. Wolf Improving Locality and Parallelism In Nested Loops Dissertation, Stanford University, 1992.

143 137 Memory Limitations in Artificial Intelligence Felix Pottmeyer 1. Grundlagen Diese Ausarbeitung beschäftigt sich mit Speicherbeschränkungen im Bereich der künstlichen Intelligenz. Sie basiert auf dem Paper Memory Limitations in Artificial Intelligence [3] von Stefan Edelkamp. Aufgabenstellungen, mit denen sich die künstliche Intelligenz beschäftigt, lassen sich oft als Suchprobleme beschreiben. Wichtige Probleme der künstlichen Intelligenz sind zum Beispiel Single Agent Suchprobleme wie das n²-1 Schiebepuzzle oder der Rubiks Cube und Multi Agent Suchprobleme wie Schach. Die in dieser Ausarbeitung vorgestellten heuristischen Suchverfahren wurden in erster Linie für Single Agent Suchprobleme entwickelt, werden (teilweise leicht abgeändert) aber auch für andere Suchprobleme benutzt. Möchte man Suchverfahren zur Lösung eines Problems anwenden, muss man das zu lösende Problem zuerst in eine Form bringen, die von einem Suchalgorithmus verarbeitet werden kann. Dazu wählt man eine Zustandsraumrepräsentation des Problems. Zustände beschreiben dabei den Informationsstand zu einem beliebigen Zeitpunkt und Zustandsübergangsoperatoren legen fest, wie man von einem Zustand in einen anderen gelangen kann. Des weiteren müssen bestimmte Zustände als Start- beziehungsweise Zielzustände gekennzeichnet werden. Gesucht wird nun eine Folge von Operatoranwendungen, die, angewandt auf einen Startzustand, diesen in eine Lösung, also einen Zielzustand überführt. Die Zustandsraumrepräsentation lässt sich als Graph darstellen. Dabei stellen die Knoten des Graphen Zustände dar und die Kanten des Graphen repräsentieren die Operatoren. Im Prinzip lässt sich ein so repräsentiertes Problem nun algorithmisch einfach lösen, indem man zum Beispiel Breitensuche benutzt. Dies ist aufgrund der Komplexität der behandelten Probleme jedoch nicht möglich, wie folgende Tabelle, die zu ausgewählten Problemen die Größe des Zustandsraums und die Brute-Force-Suchzeit angibt, verdeutlicht: Problem Zustände Brute-Force-Suchzeit (10 Millionen Zustände/s) 8 Puzzle ,01 Sekunden 3 2 Rubiks Cube ,2 Sekunden 15 Puzzle Tage 3 3 Rubiks Cube Jahre 24 Puzzle Milliarden Jahre Dabei wird davon ausgegangen, dass doppelte Zustände erkannt werden, das heißt jeder Zustand des Zustandsraums nur einmal betrachtet wird. Ist dies nicht der Fall, weil zum Beispiel nicht mehr alle schon besuchten Zustände in den Speicher passen, kann der Suchgraph auch für kleinere Probleme schon sehr viel größer werden.

144 Die oben stehende Grafik zeigt ein Beispiel für das 8-Puzzle. Links ist ein Startzustand abgebildet, rechts der Zielzustand. Gesucht ist dann eine Folge von Zügen, die den Start- in den Zielzustand überführt. Das 8-Puzzle hat einen Zustandsraum von 9!/2 = Zuständen. Wenn wir als Operatoranwendung das verschieben des leeren Feldes um eine Position ansehen, dann kann ein Zustand (je nach Position des leeren Feldes) entweder 2, 3, oder 4 Nachfolgezustände haben. Schätzen wir den Verzweigungsgrad (engl. branching factor) grob mit 3 ab, und gehen wir davon aus, dass man 20 Operatoranwendungen braucht, um in einen Zielzustand zu gelangen. Dann ist der vollständige Suchraum (ohne 20 Duplikatserkennung) 3 Zustände (fast 3,5 Milliarden) groß. Heuristiken Eine Heuristik h ist eine Funktion, die zu jedem Knoten n seinen Abstand von Zielknoten g schätzt (ein niedrigerer Wert ist dementsprechend besser). Da ein Zielzustand keinen Abstand mehr zum Ziel hat gilt immer: h(g) = 0. Seien nun h*(n) die tatsächlichen minimalen Kosten von n zu g. Eine Heuristik h heißt dann optimistisch, wenn für jedes n gilt h(n) <= h*(n). Eine optimistische Heuristik darf den tatsächlichen Abstand zum Zielknoten also niemals überschätzen. Hat man zwei Heuristiken h1 und h2, heißt h1 informierter als h2, wenn für jeden Knoten n gilt h1(n) >= h2(n). Im Allgemeinen ist es vorteilhaft, eine möglichst informierte Heuristik zu verwenden, da diese die Suche stärker einschränkt, also schlechtere Zustände schlechter bewertet. Dabei muss man jedoch beachten, dass informiertere Heuristiken oftmals auch schwerer zu berechnen sind. Konsistente Kostenfunktion Eine Kostenfunktion f(n) ist konsistent, wenn für alle Knoten n und s(n), wobei s(n) Nachfolger von n ist, f(n) <= f(s(n)) gilt. Die Kosten eines Knotens dürfen also nie geringer als die Kosten seines Vorgängers sein. Lemma: Für jede optimistische Kostenfunktion f lässt sich eine konsistente optimistische Kostenfunktion f konstruieren, die mindestens so informiert wie f ist. Beweis: f wird folgendermaßen rekursiv aus f konstruiert: ist n ein Startzustand, setze f (n) = f(n). Ansonsten setze f (s(n)) = max[f(s(n)), f (n)]. f ist monoton, da immer f (n) <= f (s(n)) gilt. Beachte, dass f (n) gleich dem höchsten Wert von f eines Vorgängers von n auf dem Pfad von n zum Startzustand ist. Da die Kostenfunktion f optimistisch ist, ist der höchste f-wert auf einem Pfad eine untere Schranke für die Kosten dieses Pfads (und damit eine untere Schranke für die Kosten von n). Deshalb gilt, dass f ebenfalls optimistisch ist. Außerdem ist f mindestens so informiert wie f, da für alle n, f (n) >= f(n) gilt. Für Analyse der im folgenden vorgestellten Algorithmen werden wir immer voraussetzen, dass die benutzten Heuristiken optimistisch und konsistent sind. External Memory Model

145 139 Für die Entwicklung und Analyse von externen Algorithmen werden wir das External Memory Model benutzen. In diesem Modell gibt es einen Prozessor und einen kleinen internen Speicher, der bis zu M Daten speichern kann. Zusätzlich gibt es einen in der Größe unbeschränkten externen Speicher. Die Eingabegröße des Problems ist durch N gegeben. Die Blockgröße B gibt an, wie viele Daten auf einmal zwischen dem internen und dem externen Speicher übertragen werden können. Als Blöcke definieren wir m = M/B und n = N/B. Für die Analyse von Algorithmen betrachten wir in diesem Modell nur die Anzahl von gelesenen oder geschriebenen Blöcken, die internen Berechnungen des Algorithmus verursachen keine Kosten. Zwei externe Operationen werden wir später benötigen, Scan und Sort. Scan bedeutet Daten zu lesen, die zusammenhängend auf der Festplatte gespeichert sind. Scan(N) benötigt θ ( N / B) = θ ( n) Input/Output-Operationen. Sort ist eine Operation, die externes Sortieren N durchführt. Sort(N) kann mit O(log M / B ) B 2. A* I/O-Operationen realisiert werden. A* ist heuristischer Best-First-Suchalgorithmus. Er betrachtet immer den Knoten als nächstes, der gemäß der Kostenfunktion den geringsten Wert hat. Die Kostenfunktion für einen Knoten n sieht dabei folgendermaßen aus: f(n) = g(n) + h(n). Dabei sind g(n) die Kosten die benötigt wurden, um zu Knoten n zu gelangen und h(n) ist der mit einer Heuristik geschätzte Abstand von n zum nächsten Zielzustand. Die Kostenfunktion f(n) beschreibt somit die geschätzten Kosten eines Pfades vom Startzustand über Knoten n zum Zielzustand. A* arbeitet auf impliziten Graphen, der Suchgraph wird also während der Laufzeit aufgebaut. Dafür werden zwei Listen verwaltet: eine Open- und eine Closed-List. Die Open-List enthält die erzeugten, aber noch nicht expandierten (d.h. ihre Nachfolger wurden noch nicht generiert) Knoten. Diese Knoten werden auch als Horizontknoten bezeichnet. Die Closed-List enthält die bereits expandierten Knoten. Zu Beginn wird die Open-List mit dem Startzustand initialisiert. Im Verlauf des Algorithmus wird dann immer der Knoten n mit den geringsten Kosten f(n) aus der Open-List genommen und expandiert. Dabei werden seine neu generierten Nachfolger in die Open-Liste eingefügt. Wird ein Knoten expandiert, der ein Zielzustand ist, terminiert der Algorithmus. Die Arbeitsweise des A* Algorithmus verdeutlicht das folgende Beispiel:

146 140 I s 2 II s 2 III s 2 A 1 B 2 A 1 B 2 C 2 D 3 E 2 IV s 2 V s 2 A 1 B 2 A 1 B 2 C 2 D 3 E 2 F 1 2 D 3 E 2 F 1 Schritt I stellt den Startzustand s dar. Dieser hat heuristische Kosten h(s) = 2. In diesem Beispiel sind die Kosten von Operatoranwendungen (die Kantengewichte) immer 1. Schritt II zeigt den Suchgraph, nachdem s expandiert wurde und seine Nachfolger A und B in die Open-List eingefügt wurden. Da f(a) = g(a) + h(a) = kleiner als f(b) = ist, wird als nächstes Knoten A expandiert. Das Ergebnis dieser Expansion ist in Schritt III zu sehen. A* expandiert immer den Horizontknoten mit dem geringsten f-wert. Schritte IV und V zeigen das Ergebnis der Expansion von Knoten B bzw. F. Knoten G stellt einen Zielknoten dar, der sich in Schritt V noch in der Open-List befindet. Es bedarf also noch eines nächsten Schrittes, der Knoten G expandiert, bevor der Algorithmus terminiert. Auf die Darstellung dieses Schrittes wurde hier verzichtet. Lemma: A* ist vollständig, das heißt A* findet eine Lösung, sofern eine existiert. Beweisskizze: Wir nehmen an, dass jede Operatoranwendung positive Kosten von mindestens e verursacht und dass ein Zielzustand g existiert. Das Expandieren von Knoten führt also zu Pfaden, deren Kosten um mindestens e (die Kosten der Operatoranwendung) steigen. Seien nun f* die Kosten des günstigsten Pfades zu g. Da A* ein Best-First-Algorithmus ist und eine optimistische Heuristik verwendet wird, werden keine Knoten expandiert, deren Kosten größer als f* sind. Da jede Operatoranwendung bzw. Knotenexpansion mindestens Kosten von e verursacht, kann kein Pfad länger als f*/e werden kann, bevor Zielzustand g gefunden wird. Lemma: A* ist optimal, das heißt der erste gefundene Zielknoten ist der günstigste Zielknoten. G 0 Beweis: Sei g2 ein nicht optimaler Zielzustand in der Open-Liste und sei n ein Knoten auf einem kürzesten Pfad zum optimalen Zielzustand g. Da g2 ein Zielzustand ist gilt h(g2) = 0 und dementsprechend f(g2) = g(g2). Da g2 nicht optimal ist gilt ebenfalls g(g2) > g(g). Da die

147 141 Heuristik h optimistisch ist gilt f(n) <= g(g) und somit auch f(n) < g(g2). Da g(g2) > f(n) wird A* niemals g2 expandieren, sondern immer vorher n und dessen Nachfolger auf dem Pfad zum günstigsten Zielknoten g. Es gibt keinen vollständigen und optimalen Suchalgorithmus, der zu einer gegebenen Heuristik h weniger Knoten expandiert als A*. Alle Knoten, die A* expandiert, haben nämlich Kosten die höchsten so groß sind wie die Kosten f* der optimalen Lösung. Würde ein Knoten, der geringere Kosten als f* hat, nicht expandiert werden, so wüsste man nicht, ob dieser Knoten eventuell eine günstigere Lösung darstellt. Die tatsächliche Laufzeit von A* hängt von der verwendeten Heuristik ab. Wird die uninformierteste Heuristik benutzt, bei der für jedes n h(n) = 0 gilt, wird A* zur Gleiche- Kosten-Suche. Sind zusätzlich die Operatorkosten immer 1, wird A* zur Breitensuche. Benutzt man hingegen eine perfekte Heuristik, wird sofort der optimale Weg zum Zielzustand gefunden. Das größte Problem von A* ist auch nicht die Laufzeit, sondern der Speicherbedarf, der bei d O( b ) liegt. Dabei steht b für den Verzweigungsgrad (branching factor) des Suchraums und d für die Tiefe der Lösung, also die benötigt Anzahl Operatoranwendungen, um vom Start- in den Zielzustand zu gelangen. Es gibt jedoch mit IDA* eine speicherbeschränkte Version von A*, deren Speicherbedarf nur O(d) beträgt und die ich im folgenden vorstellen werde. 3. IDA* Iterative Deepening A* [5] führt eine iterierte Tiefensuche aus. Dabei werden in jeder Iteration nur Knoten n expandiert, deren Kosten f(n) = g(n) + h(n) kleiner oder gleich einer Schranke t sind. Wurde in einer Iteration kein Zielzustand erreicht, wird t erhöht und eine neue Iteration gestartet. Zu Beginn des Algorithmus wird t mit dem f-wert des Startknotens initialisiert. Für die Iteration i wird die Schranke t auf den geringsten f-wert aller in Iteration i-1 gefundenen, aber nicht expandierten Knoten gesetzt. Zwischen zwei Iterationen muss so nur der Wert t gespeichert werden. Das folgende Beispiel erläutert den Ablauf einer IDA* Iteration: I s 4 II s 4 s 4 s 4 III IV A 6 A 6 A 6 B 3 V s 4 VI s 4 VII s 4 A 6 B 3 A 6 B 3 A 6 B 3 C 6 C 6 C 6 Schritt I zeigt den Startzustand s. Von dort aus wird nun eine Tiefensuche mit Schranke t = f(s) = h(s) = 4 gestartet. Wir gehen in dem Beispiel wieder von Operatorkosten bzw.

148 142 Kantengewichten von 1 aus. A, der erste Nachfolger von s, hat Kosten von = 7. Da dies größer als t ist wird A nicht expandiert und der Algorithmus geht wieder zu s. B, der zweite Nachfolger von s, hat Kosten von = 4. Da dies nicht größer als die Schranke t = 4 ist, wird B expandiert. Die Kosten des Nachfolgers von B sind wieder größer als t. Nachdem jeder Knoten u mit f(u) <= t vollständig expandiert wurde, ist die Iteration beendet. Die nächste Iteration wird dann mit dem niedrigsten f-wert eines gefundenen, aber nicht expandierten Knotens gestartet (in diesem Beispiel ist t=7). Lemma: IDA* ist vollständig und optimal. Beweisskizze: IDA* wird mit t = f(s) gestartet. Da eine optimistische Heuristik verwendet wird, gilt immer f* >= h(s). Die nächste Schranke t` ist dann der minimale f-wert eines Horizontknotens aus der letzten Iteration mit f > t. Daraus folgt, dass es keine Knoten geben kann, deren Kosten zwischen der Schranke t aus Iteration i und t aus Iteration i+1 liegen können. Da IDA* immer alle Knoten bis zu einem Kostenlimit expandiert, bevor (in der nächsten Iteration) auch Knoten mit größeren Kosten expandiert werden, ist die erste gefundene Lösung optimal. Da IDA* eine iterierte Tiefensuche ausführt, hat der Algorithmus auch einen Speicherbedarf wie Tiefensuche von O(d). Allerdings werden dafür in jeder neuen Iteration alle vorher schon expandierten Knoten wieder neu expandiert. Hat man jedoch Suchräume, die exponentiell mit der Tiefe wachsen, so dominiert der Aufwand der letzten Iteration den gesamten vorherigen Aufwand. Außerdem benutzt IDA* nur Stapeloperationen und hat deshalb pro Knoten einen deutlich geringeren Overhead als A*, welches teurere Listenoperationen benötigt. So ist IDA* in der Praxis manchmal sogar schneller als A*. IDA* war auch das erste Verfahren, das zufällig generierte Instanzen des 15er Puzzles lösen konnte. Betrachten wir nun die theoretischen Ergebnisse zur Laufzeit von IDA*. Sei n die Anzahl Knoten, die A* für die Lösung eines Problems expandiert. Dann expandiert IDA* bis zu O(c n²) viele Knoten auf der Menge der c beschränkten Graphen. Der ungünstigste Fall liegt genau dann vor, wenn in jeder IDA* Iteration nur ein neuer Knoten expandiert wird. Dann benötigt IDA* insgesamt n Iterationen und expandiert dabei in jeder Iteration alle bisher schon expandierten Knoten wieder neu: n i= 1 n( n+ 1) ci= c O cn 2 2 ( ) Abschließend lässt sich sagen, dass IDA* insbesondere gut für Probleme funktioniert, bei denen es nur wenig verschiedene f-werte gibt, da dann in jeder Iteration mehr neue Knoten expandiert werden und es somit insgesamt weniger Iterationen gibt. Dies ist zum Beispiel für n²-1 Puzzles der Fall. A* expandiert wenige Knoten, hat aber einen Speicherbedarf, der exponentiell mit d wächst. IDA* hat nur einen linearen Speicherbedarf, expandiert aber dafür O(n²) viele Knoten. Im folgenden werde ich speicherbeschränkte Verfahren vorstellen, die versuchen, die Vorteile von A* und IDA* miteinander zu kombinieren. 4. Speicherbeschränkte Verfahren Die in diesem Kapitel vorgestellten Verfahren SMA* und MEIDA* basieren auf A* und IDA*. Bei beiden Verfahren ist es so, dass ihnen eine Speichergröße zugewiesen wird. Diesen

149 143 Speicher darf das Verfahren dann mit dem Suchraum füllen. Ist der Suchraum größer als der zugewiesene Speicherplatz, kann er nicht komplett im Speicher gehalten werden. Ist der Speicher einmal voll, muss vor dem expandieren eines neuen Knotens ein anderer Knoten aus dem Speicher entfernt werden. 4.1 SMA* Simplified Memory Bounded A* [6] ist ein auf A* basierender speicherbeschränkter Suchalgorithmus. SMA* wird eine Variable M übergeben, die angibt, wie viele Knoten gespeichert werden können. Ist M größer als die Anzahl von A* für ein Problem expandierten Knoten, verhält sich SMA* genau wie A*. Eine Besonderheit von SMA* ist, dass bei der Knotenexpansion immer nur ein Nachfolger generiert wird. Deshalb enthält die Open-List von SMA* auch teilweise expandierte Knoten. Dies ist notwendig, wenn der Speicher voll ist, also bereits M Knoten gespeichert werden. Dann muss nämlich nur ein Knoten gelöscht werden, bevor ein anderer (teilweise) expandiert werden kann. Würden die Knoten immer vollständig expandiert, wüsste man nicht im Vorfeld, wie viele Knoten vor einer Expansion gelöscht werden müssen. Die Kosten eines Knotens u sind wie bei A* durch die Funktion f(u) = g(u) + h(u) gegeben und es wird immer der Knoten mit den geringsten Kosten expandiert. Gibt es mehrere Knoten mit geringsten Kosten, wird der tiefste expandiert. Ist der Speicher voll, wird der Knoten mit den höchsten Kosten gelöscht. Bei mehreren Knoten mit höchsten Kosten wird der mit der geringsten Tiefe gelöscht. Um den Informationsverlust durch das Löschen von Knoten möglichst gering zu halten, werden in SMA* Kosten von Knoten an ihre Vorgängerknoten propagiert. Dies geschieht folgendermaßen: Sobald ein Knoten u vollständig expandiert wurde, bekommt dieser als Gewicht den geringsten f-wert aller seiner Nachfolger. Werden nun ein oder mehrere Nachfolger des Knotens gelöscht, wird in diesem zusätzlich der geringste f-wert aller gelöschten Nachfolger gespeichert. Dadurch hat man eine untere Schranke für die Kosten, die anfallen, wenn man wieder unter Knoten u weitersuchen möchte (also Knoten u erneut expandieren). Folgendes Beispiel zeigt den Ablauf des SMA* Algorithmus. Im Gegensatz zu den vorherigen Beispielen stellen die Knotengewichte hier nicht die heuristischen Kosten, sondern die Gesamtkosten (f-werte) dar. Im Beispiel können M = 3 Knoten gespeichert werden. I A 20 II A 20 III A 20 IV A 25 B 25 B 25 C 30 B 25 C 30 V A 25(30) VI A 25(30) VII A 25(30) VIII A 25(30) B 25 B 25 B 25(45) B 25(45) D 45 E 50

150 144 IX A 25(30) X A 30 XI A 30 XII A 30 B 45 B 45 B 45 B 45 C 30 E 50 E 50 XIII A 30(45) XIV A 30(45) C 30 C 30 F 35 In Schritt II wurde der erste Nachfolger des Startknotens A erzeugt. Da A immer noch der Knoten mit dem geringsten f-wert ist, wird der nächste Nachfolger von A erzeugt. A wurde nun vollständig expandiert und bekommt als neue Kosten 25, den geringsten f-wert aller seiner Nachfolger (Schritt IV). Da der Speicher voll ist wird als nächstes der Knoten mit den höchsten Kosten wieder gelöscht und seine Kosten werden an den Vaterknoten propagiert. In Schritt VI wurde B expandiert, da dieser die geringsten Kosten hatte. Nun ist der Speicher voll und D, der Knoten mit den höchsten Kosten, wird gelöscht, wobei sein f-wert wieder an den Vaterknoten übergeben wird. Dann wird der nächste Nachfolger von B erzeugt. B ist jetzt vollständig expandiert und bekommt als Gewicht die Kosten seines günstigsten Kindes (Schritt IX). Die niedrigsten Kosten eines Kindes von A sind nun nicht mehr 25 sondern 30 und dementsprechend werden die Kosten von A angepasst. Der teuerste Knoten E wird gelöscht und anschließend wird zum zweiten mal der Knoten C erzeugt. Diesmal ist C allerdings der günstigste Knoten und wird expandiert, nachdem Knoten B gelöscht wurde. F, der einzige Nachfolger von C, stellt einen Zielknoten dar und der Algorithmus terminiert. SMA* generiert neue (noch nie zuvor generierte) Knoten in A* Reihenfolge und liefert deshalb immer ein optimales Ergebnis, sofern der Speicher groß genug ist, um den Lösungspfad zu speichern. Dadurch, dass SMA* den vorhandenen Speicherplatz ausnutzt, können Duplikate entdeckt werden, sofern das Duplikat noch im Speicher liegt. Allerdings hat SMA* einen so großen Overhead pro Knoten, dass es für praktische Anwendungen ungeeignet ist. Außerdem kann es passieren, dass man mehrere tiefe Pfade hat, bei denen abwechselnd ein neuer Knoten generiert wird, dann der Pfad wieder gelöscht wird und der andere Pfad wieder aufgebaut wird und an diesem ein neuer Knoten generiert wird und so weiter. SMA* geht also davon aus, dass mit hoher Wahrscheinlichkeit der nächste expandierte Knoten in der unmittelbaren Nachbarschaft des zuletzt expandierten liegt. Ist dieses jedoch nicht der Fall hat SMA* die selbe Komplexität wie IDA*. 4.2 MEIDA*

151 145 Memory Extended IDA* [2] ist ein auf IDA* basierendes speicherbeschränktes Suchverfahren, welches wieder M Knoten speichern kann. Es besteht aus zwei sich abwechselnden Phasen. In der ersten Phase werden mit einer IDA* Iteration alle Knoten, deren Kosten geringer als ein Schwellenwert t sind, expandiert. Dabei werden die M Horizontknoten (entdeckte, aber nicht expandierte Knoten) in eine Vorwärtswarteschlange D (nach f geordnet) eingefügt. In der zweiten Phase des Algorithmus wird solange das Minimum aus D expandiert, bis D leer ist. Die Söhne von aus D entfernten und expandierten Knoten werden in D eingefügt, sofern deren Kosten geringer als die maximalen Kosten in D sind. Ist D voll, muss vor dem Einfügen eines neuen Knotens in D eventuell das Maximum aus D gelöscht werden. Ist D leer, wird wieder eine neue IDA* Phase ausgeführt. Dieser wird als Schranke t der f-wert des letzten expandierten Knotens übergeben. Durch das Speichern und Expandieren von Knoten in D kann man also die benötigte Anzahl IDA* Iterationen, und damit die Anzahl insgesamt expandierter Knoten, verringern. MEIDA* ist der Algorithmus mit dem besten theoretischen Ergebnis für Suchbäume. Lemma: Sei n die Anzahl von A* expandierten Knoten und M die Anzahl Knoten, die 2 MEIDA* speichern kann. Dann expandiert IDA* O( n / M ) viele Knoten. Beweis: Sei E(i) die Anzahl in Iteration i expandierter Knoten. Da IDA* in jeder Iteration die in der letzten Iteration bereits expandierten Knoten wieder expandiert ist R(i) = E(i) E(i - 1) die Anzahl der in Iteration i neu expandierten Knoten. Sei L die letzte Iteration. Die Anzahl der insgesamt expandierten Knoten ist dann gegeben durch L i= 1 i * R( L i+ 1). Um eine obere Schranke für die insgesamt expandierten Knoten zu bekommen, formulieren wir diesen Ausdruck als Optimierungsaufgabe L max i * R( L i+ 1) mit folgenden Nebenbedingungen: i= 1 1. R(1) + R(2) R(L) = E(L) = n. 2. R(i) >= M, für 1 < i < L 3. R(1) = E(L) (L-2)M 4. R(L) >= 0 Die optimale Lösung erhält man, wenn R(i) = M, R(1) = E(L) (L-2)M und R(L) = 0 ist. Das Einsetzen dieser Werte in die Optimierungsaufgabe führt zum Zielfunktionswert L 1 i= 2 im + L( E( L) ( L 2) M ). Durch Anwendung der Gaußschen Summenformel und Ausmultiplizieren der Klammern erhält man ML 2 / 2 + ( n+ 3 M / 2) L M. Das Maximum des Zielfunktionswerts wird für L = n/m + 3/2 angenommen und ergibt einen Wert von n 2 /(2 M ) + 3 n / 2 + M / 8. Dies ist wie behauptet O(n²/M). 6. Externes A* Suchräume sind oft so groß, dass sie nicht komplett im Hauptspeicher gehalten werden können. In so einem Fall werden dann die übrigen Daten auf der Festplatte gespeichert. Da 5 6 Festplattenzugriffe aber um einen Faktor von 10 bis 10 langsamer sind als Zugriffe auf den Hauptspeicher, wurde Externes A* [4] entwickelt, eine Variation des Standard A* Algorithmus, die versucht, die benötigten Festplattenzugriffe zu minimieren. Der Algorithmus wird für implizite, ungerichtete Graphen mit uniformen Kantengewichten und einer

152 146 konsistenten Heuristik vorgestellt. In diesem Fall hat externes A* eine I/O Komplexität von O(sort( E ) + scan( V )). Da der Algorithmus auf impliziten Graphen arbeitet, werden Nachfolger eines Knotens durch Anwendung einer Regel generiert. Somit sind keine I/O Operationen notwendig, um Adjazenzlisten zu lesen. Es verbleibt die Erkennung von Duplikaten als größte Quelle von I/O Operationen. Voraussetzungen für den Algorithmus sind ein impliziter ungerichteter Graph mit uniformen Kantengewichten und eine konsistente Heuristik. Aufgrund der Konsistenz gilt für jeden Nachfolger v eines Knotens u g(v) g(u) >= h(u) h(v). In anderen Worten darf ein Knoten nie geringere Kosten (einen geringeren f-wert) als sein Vorgänger haben. Da wir von uniformen Kosten ausgehen gilt ebenfalls immer h(u) <= h(v) + 1. Da der Graph ungerichtet ist folgt h(u) h(v) <= 1. Insgesamt erhalten wir, dass der Wert von h(u) h(v) ein Element der Menge {-1,0,1} ist. Externes A* benutzt als Datenstruktur ein Array von Buckets, welche auf der Festplatte gespeichert sind. Das Bucket Open(i, j) enthält genau die Knoten u mit g(u) = i und h(u) = j. Während der Ausführung des Algorithmus gibt es immer ein grade aktives Bucket Open(i, j). Für dieses gilt, dass i + j = f min (der geringste noch nicht betrachtete f-wert) ist. Aus dem aktiven Bucket werden dann solange Knoten expandiert, bis es leer ist. Anschließend wird das nächste Bucket Open(i, j) mit i + j = f min aktiviert. Gibt es kein solches Bucket mehr, wird f min = min{i + j > f min Open(i, j)!= } gesetzt. Diese Reihenfolge Knoten zu expandieren entspricht der des internen A* Algorithmus. Dadurch ergeben sich die Eigenschaften Vollständigkeit und Optimalität ebenfalls für externes A*. Doch wie viele Buckets werden nun vom externen A* benötigt? h(s) In oben stehender Grafik aus Paper [4] sind auf der Rechtsachse die g Werte und auf der Hochachse die h Werte aufgetragen. Jeder Kreis symbolisiert ein Bucket Open(g, h). Alle Buckets, die auf einer Diagonalen liegen, haben die gleichen Kosten f = g + h. Da jede Operatoranwendung Kosten von 1 hat und h(u) - h(v) ein Element aus {-1,0,1} ist, sind f*

153 147 Nachfolger von Zuständen aus Bucket Open(g, h) entweder in Open(g+1, h-1), Open(g+1, h) oder Open(g+1, h+1). Um eine obere Schranke für die maximal benötigte Anzahl Buckets zu bekommen, kann man die Anzahl Kreise auf den Diagonalen addieren. Da nur Knoten u expandiert werden, deren Kosten f(u) = g(u) + h(u) nicht größer als die Kosten der optimalen Lösung f* sind, dürfen Buckets nur in dem durch die f* Diagonale beschränkten Dreieck vorkommen. Daraus folgt, dass die Gesamtanzahl der Buckets durch f*(f*+1)/2 beschränkt ist. Lemma: Die Anzahl Buckets Open(i, j), die A* in einem Gleiche-Kosten-Problemgraphen mit konsistenter Heuristik benötigt, ist beschränkt durch f*(f*+1)/2. Duplikate In ungerichteten Uniforme-Kosten-Zustandsgraphen können Duplikate eines Zustands in Suchtiefe i höchstens in den Suchtiefen i, i-1 und i-2 auftauchen. Da die Heuristik h eine totale Funktion ist, gilt außerdem für zwei identische Zustände u und v, dass h(u) = h(v) sein muss. Daraus ergibt sich folgendes Lemma: Lemma: Während der Ausführung von A* gilt für alle i, i, j, j mit j!= j, dass die Mengen Open(i, j) und Open(i, j ) disjunkt sind. Aus dem Lemma folgt, dass Duplikaterkennung nur für Buckets mit dem selben h-wert durchgeführt werden muss. Duplikaterkennung wird immer nur für das Bucket, welches als nächstes expandiert werden soll, durchgeführt. Zu diesem Zeitpunkt sind die anderen Buckets eventuell noch nicht vollständig generiert, so dass es passieren könnte, dass die Duplikaterkennung für diese zu einem späteren Zeitpunkt noch einmal wiederholt werden müsste. Die Duplikaterkennung zu einem früheren Zeitpunkt auszuführen wäre somit redundant. Das eigentlich Erkennen und Entfernen von Duplikaten erfolgt dann in zwei Schritten. Erstens wird das grade aktive Bucket Open(g, h) sortiert und dabei werden innerhalb des Buckets bestehende doppelte Zustände gelöscht. Diese Operation wird durch externes Sortieren realisiert. Zweitens müssen die Zustände aus den Buckets Open(g-1, h) und Open(g-2, h) von Open(g, h) subtrahiert werden. Dazu müssen diese drei bereits vorsortierten Buckets einmal parallel gescannt werden. Anschließend kann das nun duplikatfreie Bucket Open(g, h) expandiert werden. Theorem: Die I/O-Komplexität des externen A* Algorithmus auf einem ungerichteten gleiche Kosten Graph mit konsistenter Heuristik ist durch O(sort( E ) + scan( V )) I/O- Operationen beschränkt. Beweis: Jede Kante des Problemgraphen wird von der Duplikaterkennung höchstens einmal betrachtet. Jeder Zustand wird einmal erzeugt und gespeichert und dann einmal fürs externe Sortieren gelesen, einmal für die Expansion gelesen und zweimal für die Duplikaterkennung gescannt. Schritt 1 der Duplikaterkennung ist das externe Sortieren. Das Bucket Open(g, h) hat einen Zustand für jede Kante aus den drei Buckets Open(g-1, h-1), Open(g-1, h) und Open(g-1, h+1). Das externe Sortieren des Buckets Open(g, h) kann also abgeschätzt werden durch O(sort( N(Open(g-1,h-1)) + N(Open(g-1, h)) + N(Open(g-1, h+1)) )) I/O-Operationen,

154 148 wobei die Funktion N die Nachfolger eines Knotens generiert. Da jeder Knoten höchstens einmal expandiert wird, führt dies zu insgesamt O(sort( E )) I/O-Operationen. Schritt 2 der Duplikaterkennung ist das Subtrahieren der Buckets. Dabei müssen die Zustände der Buckets Open(g-1, h) und Open(g-2, h) vom denen des Buckets Open(g, h) abgezogen werden. Da die einzelnen Buckets bereits sortiert sind, reicht ein Scan der Buckets aus. Dies führt zu O(scan( N(Open(g-1,h-1)) + N(Open(g-1, h)) + N(Open(g-1, h+1)) ) + scan( N(Open(g-1, h))) + scan( N(Open(g-2, h)) )) I/O-Operationen. Die Subtraktion addiert also O(scan( V ) + scan( E )) viele I/Os zur Laufzeit. Die gesamte Duplikaterkennung benötigt also O(sort( E ) + scan( V )) viele I/O-Operationen. 7. Pattern Datenbanken Die bislang vorgestellten Techniken zielten alle darauf ab, den vorhandenen Speicherplatz zu benutzten, um einen möglichst großen Teil des Suchraums zu speichern. Im Gegensatz dazu wird bei Pattern Datenbanken [1] der Speicherplatz benutzt, um exaktere heuristische Werte zu speichern und dadurch die Suche besser zu lenken, sodass nur ein geringerer Teil des Suchraums exploriert werden muss. Als Motivation betrachten wir die Manhattan-Distanz Heuristik für das n²-1 Puzzle. Diese geht davon aus, dass zwei Puzzleteile unabhängig voneinander verschoben werden können. Dies ist im allgemeinen jedoch nicht der Fall, wie folgendes Beispiel verdeutlicht: Das linke Feld stellt den Startzustand dar und das rechte den Zielzustand. Die Manhattan Distanz ist für dieses Beispiel vier. Da sich die Wege der beiden Teile aber kreuzen, müssen sie einander ausweichen und benötigen deshalb zwei weitere Züge, also insgesamt sechs. Die Verallgemeinerung dieses Ansatzes führt zu den so genannten Pattern Datenbanken. Dabei ist ein Pattern ein Zustand einer Teilmenge der Puzzleteile. In der Pattern Datenbank wird dann zu jedem Pattern der gleichen Teilmenge die exakte Anzahl Schritte gespeichert, die benötigt wird, um dieses Pattern zu lösen. Die Lösung des Patterns stellt ein Teilziel dar, welches erreicht werden muss, um die Gesamtaufgabe zu lösen. Ist zum Beispiel das untenstehende Pattern gelöst worden, muss nur noch ein 8-Puzzle gelöst werden, um zur Gesamtlösung zu kommen.

155 Im oben stehenden Beispiel stellt das linke Feld den Startzustand dar. Dieser ist ein Pattern für die Teilmenge {3,7,11,12,13,14,15} der Puzzleteile und das leere (graue) Feld. Das rechte Feld stellt den Zielzustand dar. In der Patterndatenbank wird dann zu dem links stehenden Pattern die exakte Anzahl an benötigten Schritten für dieses Beispiel 31 gespeichert. Beachte, dass die 31 Schritte nur dafür benötigt werden, die betrachtete Teilmenge in die richtige Position zu bringen. Anschließend muss noch das verbleibende 8-Puzzle gelöst werden. Dieses hat jedoch nur 9!/2 = Zustände und könnte komplett im Voraus berechnet und gespeichert werden. Im obigen Beispiel besteht das Pattern aus den 7 Randteilen und dem leeren Feld, also insgesamt 8 Teilen. Diese 8 Teile können im 15er Puzzle 16!/(16-8)! = 518,918,400 Zustände annehmen. Die Datenbank muss für dieses Pattern also knapp 519 Millionen Einträge enthalten. Eine Pattern Datenbank muss für ein Problem nur einmal berechnet werden und kann dann immer wieder für neue Instanzen des selben Problems genutzt werden. Die Datenbank kann erstellt werden, indem vom Zielzustand aus eine Breitensuche gestartet wird. Während dieser Suche werden alle Züge gezählt. In einem Zug wird dabei entweder ein Teil des Patterns, oder eines der weißen Nicht-Pattern-Teile verschoben. Jedes Mal, wenn ein neuer Zustand erreicht wird, wird dieser dann gespeichert, zusammen mit der Anzahl an Schritten, die benötigt wurde, um ihn zu erreichen. Da das Pattern eben nur eine Teilmenge des Gesamtproblems betrachtet und löst, kann man es mit so einem Brute-Force-Ansatz effizient lösen, auch wenn dies für das Gesamtproblem unmöglich ist. Experimentelle Laufzeitüberprüfung Patterndatenbanken wurden benutzt, um 100 Testinstanzen des 15er Puzzles zu lösen. Dabei wurden zwei Pattern getestet. Das erste Pattern ist das bereits oben vorgestellte, bestehend aus den 7 Randteilen 3,7,11,12,13,14,15 und dem leeren Feld. In der unten stehenden Tabelle ist es mit FR (fringe, englisch für Rand) abgekürzt. Das zweite Pattern besteht aus den Teilen 8,9,10,12,13,14,15 und dem leeren Feld und ist in der unten stehenden Tabelle mit CO (für corner) abgekürzt. Als Algorithmus wurde IDA* benutzt. Zum Vergleich wurde IDA* mit der Manhattan-Distanz Heuristik verwendet. Experiment # Knoten Baumgröße % Verbesserung MD 36,302,808, MD + FR 105,067,478 0, MD + CO 83,125,633 0, Wie man in oben stehender Tabelle leicht sehen kann, hat die Verwendung einer Patterndatenbank mit IDA* sehr große Vorteile gegenüber der Verwendung von IDA* mit

156 150 Manhattan-Distanz gebracht. Durch Verwendung der Corner Patterndatenbank wurde die Baumgröße sogar auf 0,23% reduziert, also um einen Faktor von 437 verbessert. 8. Zusammenfassung In dieser Ausarbeitung wurden verschiedene heuristische Suchverfahren vorgestellt. A* ist das Suchverfahren, welches die wenigsten Knoten expandiert, hat dafür aber einen Speicherplatzbedarf, der exponentiell mit der Lösungstiefe wächst. IDA* expandiert auf der Menge der c-beschränkten Graphen bis zu O(c n²) viele Knoten (wenn A* n Knoten expandiert), braucht dafür aber nur O(d) Speicher. Speicherbeschränkten Verfahren wie SMA* und MEIDA* wird die Größe M des Speicherplatzes, den sie verwenden dürfen, übergeben. Ist der betrachtete Suchraum größer als dieser Speicherplatz, müssen mache bereits expandierte Knoten wieder aus dem Speicher verdrängt werden, bevor neue Knoten expandiert werden können. Es besteht das Problem, dass nicht mehr gespeicherte Zustände eventuell wieder neu erzeugt werden müssen. MEIDA* ist das Speicherbeschränkte Verfahren mit dem besten theoretischen Ergebnis für Suchbäume. Es expandiert O(n²/M) viele Knoten. Im externen A* Algorithmus wird immer der komplette bislang erzeugte Suchraum auf der Festplatte gespeichert. Der Algorithmus ordnet die Zustände auf der Festplatte so an und führt die Duplikaterkennung so aus, dass die Anzahl benötigter Festplattenzugriffe minimiert wird. Externes A* benötigt O(sort( E ) + scan( V )) I/O- Operationen. Ein anderer wichtiger Ansatz besteht darin, den Speicherplatz nicht zum Speichern des Suchraums, sondern für bessere heuristische Werte zu verwenden. Solche Werte werden z.b. in Pattern-Datenbanken gespeichert und lenken die Suche so, dass nur ein geringerer Teil des Suchraums betrachtet werden muss. 9. Literatur [1] Culberson, J. C., Schaeffer, J. Searching with Pattern Databases. In Gordon McCalla, Editor, Proceedings of the eleventh Biennial Conference of the Canadian Society for Computational Studies of Intelligence on Advances in Artificial Intelligence, volume 1081 of LNAI, pages , Springer, [2] Eckerle, J., Schuirer, S. Effiziente speicherplatzbeschränkte Graph-Such-Algorithmen. Technischer Bericht Nr. 57, Universität Freiburg, [3] Edelkamp, S. Memory Limitations in Artificial Intelligence. In Ulrich Meyers, Peter Sanders, Jop Sibeyn, Algorithms for Memory Hierarchies, pages , Springer, [4] Edelkamp, S., Jabbar, S., Schrödl, S. External A*. In Proceedings of the 27 th German Conference on Artificial Intelligence, pages , Ulm, Germany, [5] Korf, R. Depth-First Iterative Deepening: An Optimal Admissible Tree Search. In Artificial Intelligence, Volume 27, Issue 1, pages , [6] Russell, S. Efficient Memory-Bounded Search Methods. In Proceedings of the European Conference on Artificial Intelligence, pages 1-5, 1992.

157 151 Algorithmen für Speichernetzwerke Elmar Köhler 1 Einleitung In modernen Computern werden heutzutage magnetische Speichersysteme zur persistenten Speicherung von Daten verwendet. Das zu speichernde Datenvolumen wächst hierbei allerdings konstant an. Dies liegt an der globalen Verfügbarkeit der Daten, wie z.b. durch das Internet, wie auch aufgrund von immer stärker in den Vordergrund rückenden Multimediaanwendungen. Um mit dieser Menge an Informationen Schritt zu halten, reichen die sich alle Monate verdoppelnden Kapazitäten heutiger Festplatten nicht mehr aus. Es müssen andere Wege gefunden werden das Datenvolumen zu fassen. Eine simple Lösung wäre die parallele Nutzung mehrerer Platten ohne besondere algorithmische Verteilung der Daten. Sollte die Kapazität nicht mehr ausreichen, so wird eine weitere Festplatte in den Verbund aufgenommen und die neu anfallenden Daten werden auf dieser gespeichert. Dies hat zwar den Vorteil, dass man annähernd unendliche Kapazität zur Verfügung hat und sich diese durch viele kostengünstige Festplatten realisieren lässt, allerdings beinhaltet die Lösung viele Probleme. So wird die Parallelität des Festplattenverbunds nicht ausgenutzt. Ein groÿes Problem kann auftreten, wenn alle populären Daten auf einer oder wenigen Festplatten gespeichert werden. Diese Daten werden aufgrund ihrer Popularität viele Anfragen erhalten, was zu einem Flaschenhals bei dieser Festplatte führt und somit langsamerer Performance. Weiterhin wächst die Ausfallwahrscheinlichkeit mit zunehmender Festplattenanzahl enorm an. Für jede Festplatte wird vom Hersteller eine Mean-Time-To-Failure (MTTF) angegeben, welche die mittlere Zeit bis zu einem Ausfall der Platte angibt. Diese Zeit ist für eine einzelne Festplatte betrachtet recht hoch, fällt jedoch ab, je mehr Festplatten in einem Array zusammengefasst werden. Sie ändert sich zu: MT T F Array = MT T F F estplatte Anzahl F estplatten Somit ist eine einfache Lösung mit einem Array aus vielen Festplatten weder besonders performant, noch ausreichend zuverlässig. Eine ähnliche Lösungsmöglichkeit wäre die Verwendung von vielen Rechnern, welche über ein Netzwerk verbunden sind. Hierbei haben wir wieder den Vorteil, dass durch Hinzufügen weiterer Rechner die Kapazität beliebig erhöht werden kann. Die Probleme sind allerdings ähnlich denen eines simplen Festplattenarrays. Einzelne Rechner oder Verbindungen zu einzelnen Rechnern können ausfallen, was zum Verlust der Daten auf diesen führt. Weiterhin kann es wieder einen Flaschenhals für populäre Daten geben, wenn diese auf einem

158 152 Rechner gespeichert werden und jener somit viele Anfragen beantworten muss. Die Parallelität würde kaum ausgenutzt. Die genannten Probleme führen zum Bedarf nach einer allgemeineren Verwaltung von Festplatten. Speichernetzwerke können diese Verwaltung gewährleisten. Sie sind eine Sammlung von Festplatten, welche durch ein Netzwerk verbunden sind. Dies alleine betrachtet wäre noch kein Unterschied zu den vorherigen Lösungsansätzen des Kapazitätsproblems. Das besondere an Speichernetzwerken ist jedoch, dass sie Verfahren verwenden, um die Verteilung der Daten zu steuern. So werden die Daten nicht einfach auf der neusten Festplatte gespeichert, sondern über das gesamte Speichernetzwerk verteilt. Speichernetzwerke berücksichtigen das Datenverteilungsproblem, welches sich damit beschäftigt wie Daten im Netzwerk verteilt werden müssen, damit bei einer Anfrage auf einzelne Daten, die Anfrage so schnell wie möglich beantwortet werden kann. Einige Strategien zur Lösung des Problems werden in den nachfolgenden Kapiteln vorgestellt. Zunächst wird jedoch erst das Modell für Speichernetzwerke und die Anforderungen an solche präsentiert. 2 Speichernetzwerke In diesem Abschnitt werden zunächst Speichernetzwerke beschrieben, sowie ein Modell für sie vorgestellt. Im Anschluss werden wichtige Anforderungen, die an Speichernetzwerke gestellt werden, präsentiert. Abbildung 1: Modell eines Speichernetzwerks 2.1 Modell des Speichernetzwerks Ein Speichernetzwerk besteht aus einer Sammlung von n Festplatten D 1,..., D n, welche durch ein Netzwerk verbunden sind. Jede der n Festplatten ist durch eine Kapazität C i und eine Bandbreite b i charakterisiert. Die maximale Anzahl an Festplatten in dem Speichernetzwerk betrage N. Für das Netzwerk wird die gesamte Kapazität durch S = 1 i n C i ausgedrückt. Das Netzwerk kann willkürlich realisiert sein, solange alle Festplatten miteinander verbunden sind. Wenn die Charakteristiken aller Festplatten gleich sind, wird das Speichernetzwerk homogen bezeichnet, andernfalls ist es heterogen. Die Menge U = 1,..., p

159 153 bezeichnet die zu speichernden Datenelemente, welche aus dem Universum der Gröÿe M bezogen werden. Auf Festplatten wird auf Daten in der Form von Dateneinheiten einer bestimmten Gröÿe, je nach System, zugegrien. Die Gröÿe dieser Dateneinheiten wird hier als Gröÿe eines Datenelements angenommen. In Speichernetzwerken können Dateneinheiten auch redundant gespeichert werden. Diese Redundanz wird benötigt, um Ausfälle der Festplatten korrigieren zu können. Hierzu wird der Begri der Replikationsreinheit verwendet, welche eine Kopie einer Dateneinheit darstellt. Durch den Replikationsfaktor r R wird ausgedrückt, wieviele Replikationseinheiten je Dateneinheit im Speichernetzwerk vorhanden sind. Ein Faktor von 1 steht hierbei für je eine Kopie je Dateneinheit. Somit ergibt sich die Gesamtanzahl an gespeicherten Elementen im Netzwerk zu m = 1 i p i + r i. Abbildung 1 zeigt das Modell für Speichernetzwerk grasch. 2.2 Anforderungen an Speichernetzwerke Speichernetzwerke unterliegen verschiedenen Anforderungen. Diese sind vielfältig und können nicht alle gleichzeitig erfüllt werden. Folgende Anforderugnen werden gestellt: Last- und Zugrisbalancierung: Beschreibt die Anforderung, die Daten möglichst gleichmäÿig über alle Festplatten zu verteilen. Dies gewährleistet eine gute Plattenausnutzung und erhöht die Parallelität bei der Abarbeitung von Datenanfragen. Verfügbarkeit: Viele Festplatten in einen Array erhöhen die Ausfallwahrscheinlichkeit. Aus diesem Grund müssen Speichernetzwerke sicherstellen, dass die Daten auch im Falle eines Fehlers wieder Verfügbar gemacht werden können. Dies kann durch Redundanz und Mechanismen zur Datenrekonstruktion erreicht werden. Ressourcenezienz: Die Ressourcen eines Speichernetzwerks sollen nicht verschwendet werden. Das Problem der Verfügbarkeit könnte z.b. durch viele Kopien der Dateneinheiten realisiert werden, sodass Festplattenausfälle kompensiert werden können. Diese Lösung verwendet allerdings einen Groÿteil der Speicherressourcen für Kopien und nur einen kleinen Teil für die eigentlichen Daten. Somit wäre viel Speicherplatz verschwendet. Daher ist es wichtig die Ressourcen ezient zu nutzen, vor allem, wenn die Gröÿe der Speichernetzwerke stark zunimmt. Zugrisezienz: Daten im Speichernetzwerk sollen möglichst schnell erreicht werden. Wenn auf Daten nicht zugegrien werden kann, sind sie verloren. Daher soll die Zeit für einen Datenzugri möglichst kurz sein. Dies kann z.b. durch Parallelität bei der Abarbeitung von Anfragen realisiert werden, oder indem gröÿere Mengen an Dateneinheiten auf einmal eingelesen werden. Heterogenität: Speichernetzwerke können nicht immer aus homogenen Festplattenarrays bestehen. Speziell, wenn Netzwerke erweitert werden müssen, kann nicht sichergestellt werden, dass Festplatten mit gleichen Charakteristiken, wie die bereits vorhandenen Platten, verwendet werden. Diese Anforderung beschreibt wie Speichernetzwerke Festplatten mit unterschiedlichen Kapazitäten und Bandbreiten verwalten.

160 154 Adaptivität: Speichernetzwerke sind nicht statisch. Sie verändern sich im Laufe der Zeit, wie z.b. wenn die Kapazität des Netzwerks nicht mehr ausreicht und weitere Festplatten verwendet werden müssen. Die Adaptivität beschreibt dabei die Fähigkeit des Netzwerks sich an die Veränderungen anzupassen. Wie geschickt werden die bereits gespeicherten Daten umstrukturiert, um die neu hinzugewonnene, oder auch verlorene, Festplatte zu nutzen. Eine adaptives Speichernetzwerk muss hierbei nur einen geringen Teil der gespeicherten Daten umstrukturieren. Lokalität: Lokalität beschreibt den Kommunikationsaufwand, um eine Datenanfrage zu beantworten. Sind die Daten z.b. räumlich nah auf den Festplatten angeordnet, sodass sie nacheinander gelesen werden können oder muss der Kopf der Festplatte groÿe Wege zurücklegen. Die Datenverteilungsstrategien können nicht alle diese Anforderungen optimal erfüllen. Es müssen Kompromisse getroen werden je nachdem, in welchem Kontext das Speichernetzwerk eingesetzt werden soll. In den nachfolgenden Kapiteln werden Strategien vorgestellt, welche ihr Hauptaugenmerk auf einzelne der oben genannten Anforderungen legen. 3 Striping Die Motivation hinter der Entwicklung des Stripings war es eine Last- und Zugrisbalancierung zu erreichen. Die Daten sollten möglichst gleichmässig über alle Festplatten verteilt werden. Dies sollte vermeiden, dass einige Festplatten besonders viele Anfragen erhalten, während andere kaum beansprucht werden. Eine gleichmäÿige Verteilung ermöglicht auch die Parallelität des Array besser auszunutzen, da Anfragen gleichzeitig bearbeitet werden können. Striping ist ein deterministischer Ansatz, wo genau vorgegeben wird, welche Dateneinheit auf welcher Festplatte gespeichert werden soll. Hierbei wird die Menge der p Dateneinheiten in Stripes eigenteilt und auf die Festplatten verteilt. Hierzu ist es erstmal notwendig zu denieren, was ein Stipe genau ist. Ein Stripe ist eine Sammlung von l aufeinanderfolgenden Dateneinheiten, in der auch Replikationseinheiten innerhalb eines Stripes vorkommen können. Die Länge eines Stripes wird meist so gewählt, dass sie der Anzahl Festplatten im Speichernetzwerk entspricht. Die Abbildung 2: Aufteilung der Dateneinheiten in Stripes Abbildung 2 zeigt die Aufteilung der Dateneinheiten in Stripes. Die Dateneinheiten werden nacheinander den Stripes zugeordnet, wobei hier eine Stripelänge von 8 gewählt wurde. Die Verteilung der Daten auf die Festplatten erfolgt dann in ganzen Stripes. Dabei werden die Dateneinheiten folgendermaÿen den Festplatten zugeordnet. Die i-te Dateneinheit gelangt

161 155 auf Festplatte D i mod n, wenn die Stripelänge auf die Anzahl n der Festplatten gesetzt wurde. In Abbildung 3 wird gezeigt, wie ein Stripe auf die Festplatten D 1,..., D 6 verteilt wird. Abbildung 3: Zuordnung der Stripes Die Stripelänge wurde auf die Anzahl der Festplatten gesetzt. An dieser Abbildung lassen sich die Vorteile des Striping beschreiben. Man sieht, wie sich eine gleichmässige Lastverteilung über die Festplatten ergibt. Weiterhin können ganze Stripes komplett parallel zugegrien werden, sodass die Performance des Systems steigt. Allerdings können diese Vorteile nur so lange optimal ausgenutzt werden, solange die Kapazität der Festplatten homogen ist. Bei heterogenen Festplatten wird Kapazität verschwendet, sobald die kleinste Platte gefüllt ist, bzw die Stripes können nicht mehr über alle Festplatten verteilt werden und manche Festplatten müssen mehrere Dateneinheiten eines Stripes aufnehmen. Dies reduziert die gewonnene Performance des Striping. Ein weiterer Nachteil tritt auf, wenn sich die Anzahl der Festplatten im Speichernetzwerk ändert. Um keine überlappenden Stripes zu haben und um die Performance des Speichernetzwerks optimal ausnutzen zu können, muss die Stripelänge angepasst werden. Dies führt jedoch zu einer kompletten Neustrukturierung der Dateneinheiten, was bei groÿen Speichernetzwerken einen enormen Aufwand bedeutet und nicht ezient ist. Weiterhin hat dieses Verfahren keine Möglichkeit bei einem Festplattenausfall die Daten zu rekonstruieren. Eine Strategie zur Performancesteigerung und zur Rekonstruktion der Daten im Fehlerfall stellt das nächste Verfahren dar. 4 RAID RAID [2] wurde 1988 von Patterson, Gibson und Katz als Alternative zu einzelnen, teuren Festplatten vorgestellt. Die Motivation hinter RAID liegt darin die Performance und die Verfügbarkeit von Festplattenarrays zu erhöhen. Hierzu sollten mehrere, homogene Festplatten einer festen Anzahl zu einem Array zusammengeschlossen werden. Diese Form, die Kapazität eines Speichernetzwerks zu erhöhen, ist günstiger als einzelne, groÿe und teure Festplatten zu verwenden. Besondere Aufmerksamkeit wird hierbei den Fehlern innerhalb des Array gewidmet. Wie in Kapitel 1 beschrieben, nimmt die MTTF mit zunehmender Anzahl Festplatten stark ab. Somit müssen Mechanismen verwendet werden, um im Falle von Plattenfehlern die Daten dennoch wieder rekonstruieren zu können. Patterson, Gibson und Katz haben RAID in verschiedene Level eingeteilt, welche im nächsten Abschnitt

162 156 beschrieben werden. 4.1 RAID-Level Übersicht RAID 0 dient der Performancesteigerung. Bei dieser Strategie werden noch keine Mechanismen zur Fehlerbehebung verwendet. Es wird simples Striping, wie in Kapitel 3 beschrieben, verwendet. Die Nachteile sind weiterhin, dass bei einem Fehler die Daten nicht rekonstruiert werden können. Ein erster Ansatz zur Lösung dieses Problems wird in RAID 1 verwendet. Hierbei wird von jeder gespeicherten Dateneinheit eine Kopie auf einer physikalisch anderen Festplatte gespeichert. Diese Redundanz hat den Vorteil, dass bei einem Fehler die defekte Festplatte ausgetauscht und die Daten von der Sicherheitskopie wiederhergestellt werden können. Bei RAID 1 wird jedoch nicht die Parallelität der verschiedenen Festplatten ausgenutzt. Die Performance ist weiterhin wie bei einer einzigen Festplatte. Eine geringe Performancesteigerung tritt jedoch beim Lesen von Dateneinheiten auf, da hier ein alternierendes Lesen zwischen dem Original und der Kopie nötig ist. Beim Schreiben müssen jedoch die Kopie und das Original aktualisiert werden. Des Weiteren ist dieses Verfahren sehr ressourceninezient. Man benötigt doppelt soviel Speicherplatz für seine Daten. Ein Ansatz, um sowohl die Performance, als auch die Verfügbarkeit ezient zu erhöhen stellt RAID 4 dar. Hierbei werden die Dateneinheiten, wie bei RAID 0, in Stripes eingeteilt und auf die Festplatten aufgeteilt. Dies erhöht jedoch erst die Performance und die Verfügbarkeit wurde noch nicht sichergestellt. Zu diesem Zweck werden Parity Informationen verwendet und auf einer extra Festplatte abgespeichert. Die Parity Informationen ergeben sich durch die bitweise XOR-Bildung über die einzelnen Dateneinheiten innerhalb eines Stripes. Das XOR ist in Hardware direkt abbildbar und somit eine sehr schnelle Operation. Des Weiteren lässt sich beim Fehlen eines einzelnen Bits, z.b. dass eine Dateneinheit verloren gegangen ist, diese mit Hilfe der anderen direkt wieder rekonstruieren Angenommen die ersten 3 Spalten repräsentieren jeweils die Dateneinheiten auf einer Festplatte und die 4te Spalte repräsentiert die Parity Information. Fällt nun zum Beispiel die Festplatte, welche durch Spalte 2 repräsentiert wird aus, so sind die Informationen auf dieser Festplatte durch XOR-Bildung über die Festplatte 1, 2 und die Festplatte mit den Parity Informationen wiederherstellbar. Für den ersten Stripe (durch die erste Zeile repräsentiert wäre dies (0 XOR 0) XOR 1 = 1 und man erhält die Information auf der 2ten Festplatte wieder. Die anderen Stripes lassen sich analog wiederherstellen. Wir haben somit bei RAID 4 Striping, welches die Performance erhöht, und durch die Parity Information eine Möglichkeit Fehler auszugleichen. Der Vorteil der Parity Information im Vergleich zu RAID 1 liegt darin, dass mit einer Parity Information mehrere Dateneinheiten gesichert werden können. Es wird nicht mehr eine Kopie pro Dateneinheit benötigt, sondern für mehrere Dateneinheiten brauchen wir nur einen Parityeinheit. Das Schreiben einzelner Dateneinheiten ist durch die Parity Information auch nicht komplizierter geworden. Bei RAID 1 musste man im Falle einer Änderung

163 157 sowohl das Original als auch die Kopie schreiben. In RAID 4 benötigt man die Information, die vorher in der Dateneinheit stand, die neue Information und die alte Parity Information. Mit diesen lässt sich die neue Parity Information folgendermassen bilden: new parity = (old data XOR new data) XOR old parity Ein Nachteil von RAID 4 tritt allerdings auf, wenn nicht ganze Stripes geschrieben werden, sondern nur einzelne Dateneinheiten. Hierbei bildet die Parity Information einen Flaschenhals, da diese auf eine Festplatte liegt und auf diese Informationen nur nacheinander zugegrien werden kann. Abbildung 4 zeigt dieses Problem. Das x in den Dateneinheiten soll Abbildung 4: Small-Write Problem in RAID 4 hierbei einen schreibenden Zugri darstellen. Der Schreibzugri auf die 2te Dateneinheit im 2ten Stripe führt dazu, dass die Parity Information auf Festplatte D 5 aktualisiert werden muss. Ebenso müss für den Schreibzugri auf die 3te Dateneinheit im 1ten Stripe die ParityInformation auf Festplatte D 5 verändert werden. Die Zugrie auf die Dateneinheiten können parallel durchgeführt werden, da sie auf unterschiedlichen Festplatten liegen. Allerdings liegen die Parity Informationen auf einer Festplatte, was zu einem Flaschenhals führt. Beim Schreiben ganzer Stripes tritt dieses Problem nicht auf, daher wird dies auch das Small-Write-Problem genannt. RAID 5 versucht dieses Problem zu lösen, indem die Parity Informationen nicht auf einer einzelnen Platte, sondern über die Stripes verteilt gespeichert werden. Hierdurch wird beim Schreiben einzelner Dateneinheiten nicht immer eine einzige Festplatte zum Aktualisieren der Parity Informationen benötigt. Der Schreibaufwand verteilt sich über alle Festplatten. Abbildung 5 zeigt wie sich die Parity Informationen über die Stripes verteilen. Will man Abbildung 5: Small-Write in RAID 5 nun einzelne Dateneinheiten schreiben, wie z.b. die 1te Dateneinheit im 4ten Stripe und die 3te Dateneinheit im 2ten Stripe so benötigt, man zum Aktualisieren der Parity Informationen einmal die Information auf Festplatte D 2 für den 4ten Stripe und einmal die

164 158 Information auf Festplatte D 4 für den 2ten Stripe. Diese Schreibvorgänge können parallel ausgeführt werden, da die Daten auf unterschiedlichen Festplatten liegen, was zu einer Performancesteigerung führt. Mit RAID 5 ist somit ein Verfahren gegeben, welches die Performance durch Striping erhöht, sowohl beim Schreiben ganzer Stripes, als auch bei einzelnen Dateneinheiten innerhalb eines Stripes, sowie durch Parity Information die Verfügbarkeit ressourcenezient sicherstellt. Die Nachteile liegen allerdings darin, dass RAID nur einen Fehler korrigieren kann. Tritt ein weiterer Fehler auf, während die Dateneinheiten noch nicht rekonstruiert wurden, so sind die Informationen auf beiden Festplatten verloren. Eine Möglichkeit 2 Fehler zu korrigieren stellt EVENODD dar, wobei zusätzlich Parity Informationen über die Diagonalen gebildet werden. Dieses Verfahren wird hier jedoch nicht weiter vorgestellt. Weiter Schwächen von RAID liegen in den homogenen Festplatten und der Beschränkung auf eine feste Anzahl Festplatten, da bei Änderung dieser die Stripegröÿe angepasst und alle Daten neu verteilt werden müssen. Ein Verfahren, welches mit heterogenen Festplatten arbeiten kann, wird im nächsten Kapitel vorgestellt. 5 Adapt Raid Adapt Raid beschäftigt sich mit dem Problem heterogener Festplatten in Speichernetzwerken. Heterogene Speichernetzwerke können z.b. durch den Ausfall einer Festplatte entstehen. Es kann nicht garantiert werden, dass Festplatten mit denselben Charakteristiken verwendet werden können, sodass die Administratoren von Speichernetzwerken darauf angewiesen sind neuere Festplatten beim Austausch zu nutzen, die oftmals höhere Kapazität und Bandbreite im Vergleich zu den bisher verwendeten Festplatten haben. Mit der dadurch entstanden Heterogenität umzugehen, ist eine schwierige Aufgabe, wenn man weiterhin versucht die Last- und Zugrisbalancierung aufrecht zu erhalten. Eine Möglichkeit mit der Heterogenität umzugehen, ist das Zusammenfassen von Festplatten zu Clustern anhand ihrer Charakteristiken. Innerhalb eines Clusters können die Daten mit Hilfe bisheriger Strategien platziert werden. Der Vorteil hiervon ist die leichte Erweiterbarkeit, wenn das Speichernetzwerk vergröÿert werden muss. Allerdings verringert dieser Ansatz nicht die Zeit, um eine Anfrage zu beantworten. Nur Daten auf neueren Festplatten werden deutlich schneller bearbeitet. AdaptRaid versucht RAID zu erweitern und bezieht sich dabei auf RAID 0 und RAID AdaptRaid Level 0 AdaptRaid Level 0 [3] verwendet die Annahme, dass neuere Festplatten in der Regel schneller sind und gröÿere Kapazität besitzen. Daher sollten diese Festplatten mehr Anfragen behandeln. Nichts desto trotz sollte die Parallelität des gesamten Speichernetzwerks so lange wie möglich ausgenutzt werden. Die Idee dabei ist es so lange Stripes der Länge l = n auf die Festplatten zu verteilen, bis die k kleinsten Festplatten gefüllt sind. Sobald dies geschehen ist, werden Stripes der Länger n k auf die Festplatten verteilt bis wieder ein Teil der Festplatten voll ist und die Stripelänge erneut angepasst werden muss. Dieses Vorgehen wird so lange ausgeführt, bis alle Festplatten gefüllt sind. Abbildung 6 zeigt dies beispielhaft für n Festplatten. Bei diesem Ansatz werden die Charakteristiken der Festplatten und die gesamte Kapazität zwar ausgenutzt, jedoch werden die neuesten Daten auf

165 159 Abbildung 6: AdaptRaid Level 0 - Beispielverteilung die wenigen, schnellen Festplatten gespeichert. Wenn nun viele Anfragen auf diese Daten gestellt werden, so wird die Parallelität des Arrays kaum ausgenutzt. Hierzu können Muster eingeführt werden, welche vom Administrator des Speichernetzwerks deniert werden müssen. Für die Muster müssen 2 Parameter gesetzt werden. Zum Einen der Utilization Factor (U F [0, 1]), welcher die Ausnutzung jeder Festplatte im Verhältnis zur Gröÿten angibt und die Lines in a pattern (LIP), welcher die Anzahl Zeilen in einem Pattern angibt. Abbildung 7 zeigt solch ein Muster. Hierbei gelten die Werte UF 1 = 1, UF 2 = 0.83, Abbildung 7: Muster für AdaptRaid Level 0 UF 3 = 0.66, UF 4 = 0.33 und LIP = 6. Dieses Muster wird nun bei der Verteilung der Da- Abbildung 8: Anwendung eines Musters teneinheiten immer wieder angewandt, sodass bei zweimaliger Anwendung eine Verteilung

166 160 wie in Abbildung 8 erreicht wird. Der Vorteil hierin liegt in der Tatsache, dass nun auch weiterhin bei den neuesten Daten die Parallelität komplett ausgenutzt werden kann, wie z.b. bei den Dateneinheiten 28, 29 und 30, welche über 3 Festplatten verteilt sind. Weiterhin wird die Kapazität des Speichernetzwerks komplett ausgenutzt. Performancetests mit mehrere schnellen und langsamen Festplatten ergaben eine 8-20 %ige Beschleunigung beim Lesen und %ige Beschleunigung beim Schreiben [3], welche auf die Verschiebung der Daten zu den schnelleren Festplatten zurückzuführen ist. Der Zugri auf Dateneinheiten geschieht in 2 Schritten. Zuerst muss das richtige Muster gefunden werden, welches über die LIP recht einfach erreicht werden kann. Als nächstes wird dieses Pattern angewendet, um so die Dateneinheit zu nden. Der Platzbedarf hierfür ist proportional zur Gröÿe des Musters selbst. 5.2 AdaptRaid Level 5 AdaptRaid Level 5 [4] geht ähnlich wie AdaptRaid Level 0 vor. Es werden so lange Stripes der maximalen Länge zugeordnet bis ein Teil der Festplatten voll ist und die Stripelänge angepasst werden muss. Allerdings wird die Stripelänge bei diesem Verfahren auf einen ganzzahligen Teiler der maximal möglichen Stripelänge im Speichernetzwerk festgelegt. Dies hat den Vorteil, dass das darüberliegende System keine unterschiedlichen Stripelängen berücksichtigen muss, sondern nur die kleinste Stripelänge kennt. Das Speichernetzwerk erledigt die Umrechnung wieviele Dateneinheiten parallel gelesen oder geschrieben werden können. Abbildung 9 zeigt eine Verteilung der Dateneinheiten mit AdaptRaid Level 5. Die Abbildung 9: Initiale Verteilung mit AdaptRaid Level 5 maximale Stripelänge ist 4 mit Parity Information und die geringste Länge ist 2 mit Parity Information. Die Parity Information sind wie bei RAID 5 über die Stripes verteilt. Die Zahlen in den Dateneinheiten geben die Blocknummer und den zugehörigen Stripe an, während in den Parity Informationen nur der zugehörige Stripe angegeben ist. Ein Nachteil ist hierbei die geringe Auslastung von Festplatte D 4 mit nur 33%. Hier wird die Last- und Zugrisbalancierung verletzt. Durch das Verteilen der Stripes über alle Festplatten, wie in Abbildung 10 zu sehen, kann die Last über alle Festplatten balanciert werden. Allerdings wird weiterhin Kapazität verschenkt. Diese Löcher in der Verteilung können mit einem Tetris-ähnlichen Algortihmus gefüllt werden. Hierbei rücken darüberliegende Dateneinheiten eine Zeile tiefer und schliessen die Lücken. Der Zugri auf eine Dateneinheit wird über 3 Tabellen sichergestellt. Beim Starten des Speichernetzwerks werden die Tabellen angelegt. Die erste Tabelle beinhaltet für jeden Block in einem Pattern die Festplatte und die Position auf dieser, die Zweite hält die Position

167 161 Abbildung 10: Verschieben der Stripes in AdaptRaid Level 5 der Parity Information für jeden Stripe und die dritte Tabelle speichert die Anzahl Blöcke, die jede Festplatte in einem Pattern hat. Hierdurch ist der Zugri auf eine Dateneinheit ein simples look-up, was in O(1) durchgeführt werden kann. Der Nachteil hierbei liegt im Overhead, der durch die Tabellen erzeugt werden kann. Cortes und Labarta [4] sagen zwar, dass die Tabellen in ihren Experimenten nicht allzu groÿ geworden sind, allerdings kann dies bei wirklich groÿen Speichernetzwerken durchaus mit erheblichem Rechen- und Speicheraufwand verbunden sein. Des Weiteren ist dieses Verfahren nicht adaptiv. Auch wenn der Name Anderes vermuten lässt, so müssen bei einer Änderung der Festplattenanzahl im Speichernetzwerk alle Daten neu verteilt werden, wenn man die neu hinzugewonnen Kapazität und Bandbreite ausnutzen will. Weitere Verfahren, um mit der Heterogenität in Speichernetzwerken umzugehen stellen HERA [5] und RIO [6] dar. HERA teilt alle Festplatten in so genannte Extents gleicher Gröÿe auf und fasst diese Extents zu Parity Gruppen zusammen. Die Parity Gruppen haben somit gleiche Kapazität und die Daten lassen sich gleichmäÿig über alle Festplatten verteilen, um eine Last- und Zugrisbalancierung sicherzustellen. Die Stripelänge wird hierbei auf die Anzahl der Extents pro Parity Gruppe gesetzt, sodass ein Extent der Gröÿe einer Dateneinheit entspricht. RIO ist ein Ansatz aus dem Multimedia Bereich. Es verwendet eine randomisierte Verteilungsstrategie, wo jede Dateneinheit auf eine zufällige Festplatte an einer zufälligen Position gesetzt wird. Um mit Heterogenität umzugehen, wird bei der zufälligen Verteilung ein Parameter berücksicht, welcher das Bandbreiten-zu- Speicherplatz-Verhältnis je Festplatte ausdrückt. Je höher dieser ist, um so schneller kann eine Festplatte auf Dateneinheiten zugreifen und sollte somit auch mehr Dateneinheiten erhalten als Festplatten mit geringerem Verhältnis. Genauer wird in dieser Ausarbeitung auf diese beiden Verfahren nicht eingegangen. 6 Cut-and-Paste Die Cut-and-Paste Strategie [7] stellt ein adaptives Verfahren zur Verwaltung von Speichernetzwerken dar. Da sich die Anzahl der Festplatten in Speichernetzwerken aufgrund des steigenden Bedarfs an Kapazität schnell ändern kann, sind adaptive Verfahren immer wichtiger. Cut-and-Paste ist ein Ansatz, der in 2 Phasen vorgeht. Zuerst wird der Adressraum von 1,..., p deniert, wobei p sehr groÿ gewählt werden sollte, da das Verfahren nicht mit nachträglich hinzukommenden Dateneinheiten umgehen kann, wenn diese nicht schon zu Beginn bekannt sind oder entsprechende Platzhalter verwendet wurden. Dies liegt daran, dass die Cut-and-Paste Strategie eine Ordnung auf den Dateneinheiten verwendet, welche

168 162 durch nachträgliches Einfügen von Dateneinheiten gestört werden würde. Wenn der Adressraum deniert wurde, wird dieser mit Hilfe einer zufällig ausgewählten Hash Funktion h auf das [0, 1) Intervall zugeordnet (der Hashwert h(b) gibt die Höhe im Intervall an). Durch die Hashfunktion erhalten wir eine gleichmäÿge Verteilung über das Intervall und somit eine Lastbalancierung. Im zweiten Schritt wird das Intervall in Bereiche aufgeteilt, welche mit Hilfe einer Assimilierungsfunktion f auf die Festplatten verteilt werden. Wenn zwei Bereiche auf eine Festplatte zugeordnet werden, so werden diese zusammengefasst und so angepasst, dass der gesamte Bereich auf dieser Festplatte fortlaufend ist. Dies ist wichtig, um die Ordnung über den Dateneinheiten aufrecht zu erhalten. Abbildung 11 zeigt wie Abbildung 11: Zuordnung durch Cut-and-Paste man sich die 2 Phasen vorstellen muss. Die Assimilierungsfunktion geht allerdings iterativ vor. Initial nimmt sie an, dass nur eine Festplatte zur Verfügung steht und weist das gesamte [0, 1] Intervall dieser Festplatte zu. Im nächsten Schritt geht sie von 2 Festplatten aus und teilt das Intervall auf die beiden Festplatten auf. Dies geschieht so lange, bis das Intervall auf alle n Festplatten verteilt ist. In Abbildung 12 ist zu sehen wie der Übergang Abbildung 12: Hinzufügen einer Festplatte von n > n + 1 Festplatten vor sich geht. Von jeder der bisherigen n Festplatten wird der Bereich [1/n, 1/(n + 1)] abgeschnitten und mit dem Bereich von der n-ten Festplatte

169 163 beginnend auf die neu hinzugekommene (n+1)-te Festplatte verschoben. Diese Vorgehensweise führt die Assimilierungsfunktion von 1 bis n durch. Den umgekehrten Fall, dass eine Festplatte entfernt werden muss, kann die Cut-and-Past Strategie ähnlich behandeln. Hierzu wird der letzte Schritt der Assimilierungsfunktion rückgängig gemacht. Abbildung 13 Abbildung 13: Entfernen einer Festplatte - Schritt 1 zeigt, wie die Dateneinheiten von der (n + 1)-ten Festplatte auf die anderen n Festplatten zurück verteilt werden. Jede Festplatte erhält dabei die gleiche Menge an Dateneinheiten, sodass die Lastverteilung sichergestellt ist. Wenn die Dateneinheiten zurückverteilt wur- Abbildung 14: Entfernen einer Festplatte - Schritt 2 den, werden die Daten von der zu entfernenden Festplatte D 2 auf die leere Festplatte D n+1 kopiert und die Festplatte D 2 kann aus dem Speichernetzwerk entfernt werden. Abbildung 14 zeigt diesen Vorgang. Es ist nicht möglich die Daten von der zu entfernenden Festplatte einfach auf die übrigen Festplatten zu verteilen. Dies würde die Ordnung innerhalb der Dateneinheiten zerstören und ein späteres Aunden von Dateneinheiten im Speichernetzwerk unmöglich machen. Der Zugri auf eine bestimmte Dateneinheit wird über die Simulation der Assimilierungsfunktion von 1 bis n erreicht, was nur durch eine durchgehend aufrecht erhaltende Ordnung auf den Dateneinheiten sichergestellt werden kann. 6.1 Analyse Für die Analyse von adaptiven Datenverteilungsstrategien ist der Begri der competitiveness wichtig. Dieser vergleicht eine zu analysierende Strategie mit einer optimalen Strategie. Man geht davon aus, dass eine optimale Strategie nur so viele Dateneinheiten ver-

170 164 schieben muss, wie auf die neue Festplatte gelangen sollen, bzw von der zu entfernenden Festplatte entfernt werden müssen. 1-competetive gibt hierbei an, dass die Strategie genauso gut wie eine optimale Strategie ist. Betrachtet man das Hinzufügen einer neuen Festplatte in der Cut-and-Paste Strategie, so sieht man, dass nur so viele Dateneinheiten bewegt werden, wie auf die neue Festplatte müssen. Von jeder anderen Festplatte wird ein kleiner Teil der Dateneinheiten auf die neue Festplatte verschoben. Daher ist die Cut-and-Paste Strategie 1-competetive beim Hinzufügen von weiteren Festplatten. Für das Entfernen einer Festplatte müssen die Dateneinheiten einmal von der letzten Festplatte auf alle anderen zurückverteilt werden und danach von der zu entfernenden Festplatte auf die frei gewordene verschoben werden. Die Dateneinheiten müssen also 2 mal bewegt werden, wodurch die Cut-and-Paste Strategie 2-competetive beim Entfernen einer Festplatte ist. Die Cut-and-Paste Strategie stellt eine gute Last- und Zugrisbalancierung sicher. Die Hashfunktion verteilt die Dateneinheiten gleichmässig auf das [0, 1] Intervall, welches anschliessend auf die Festplatten verteilt wird. Dies kann mit Hilfe einer Binomialverteilung betrachtet werden. Das [0, 1] Intervall wird hierbei in Bereiche der Länge l aufgeteilt (wobei l = 1/n gilt). Da das Intervall genau die Länge 1 hat, ist die Wahrscheinlichkeit, dass eine der p Dateneinheiten in einen bestimmten Bereich fällt genau l. Die Wahrscheinlichkeit für eine Dateneinheit nicht in den Bereich zu gelangen ist das Gegenereignis 1 l. Wenn wir nun p Dateneinheiten auf das [0, 1] Intervall verteilen ergibt sich für diesen Bereich eine Anzahl von p l Dateneinheiten, aufgrund des Erwartungswertes der Binomialverteilung. Da alle Bereiche gleich groÿ sind, ist die Anzahl Dateneinheiten für jeden Bereich p l. Nun muss sichergestellt werden, dass bei Veränderungen der Festplattenanzahl die Lastbalancierung erhalten bleibt. Wenn eine neue Festplatte D n+1 in das Speichernetzwerk eingeführt wird, wird von jeder der n Festplatten der Bereich [1/n, 1/(n + 1)] abgeschnitten und auf D n+1 zugeordnet. Hierdurch bleibt auf jeder der n Festplatten noch ein 1/(n + 1)-te Teil des [0, 1] Intervalls. Auf die neue Festplatte gelangt n mal der abgeschnittene Bereich, was n [ 1 n 1 n + 1 ] = n 1 n (n + 1) = 1 n + 1 ergibt und somit die Lastbalancierung aufrecht erhält. Beim Entfernen einer Festplatte wird diese Balancierung ebenfalls nicht verletzt, da das Hinzufügen nur rückgängig gemacht wird und somit das Speichernetzwerk in den Zustand vor dem Hinzufügen zurückversetzt wird, welcher der Anforderung der Last- und Zugrisbalancierung genügt hat. Der Zugri auf eine Dateneinheit geschieht, wie weiter oben erwähnt, durch die Simulation der Assimilierungsfunktion von 1 bis n Festplatten. Die Laufzeit für diese Simulation beträgt O(log n). Hierzu muss man die Anzahl der Verschiebungen einer einzelnen Dateneinheit betrachten. Nimmt man eine beliebige Dateneinheit b auf einer beliebigen Festplatte D n0, so muss b auf die nachfolgende Festplatte D n1 verschoben werden, wenn 1 n 1 h n0 (b) gilt. Auf Festplatte D n1 entspricht die Höhe der Dateneinheit b der Höhe im abgeschnitten Bereich von D n0, da der Bereich der vorhergehenden Festplatte zuerst auf die nachfolgende Festplatte zugeordnet wird. Dies entspricht der Höhe h n1 (b) n 1 n 0. n 1 (n 1 1)

171 165 Das nächste Mal wird die Dateneinheit b nun bewegt, wenn sie sich im Bereich der Festplatte D n1 bendet, welcher auf D n2 verschoben werden muss. Hierfür muss h n1 1 n 2 gelten, was mit h n1 (b) n 1 n 0 n 1 (n 1 1) und nach n 2 umgeformt folgenden Term ergibt: n 2 n 1 (n 1 1) n 1 n 0. Um herauszunden wie groÿ die Sprünge der Dateneinheiten beim Verschieben sind, muss dieser Term minimiert werden. Dazu werden für folgende Funktion, welche sich aus dem obigen Term ergibt, die Tiefpunkte berechnet f(n 1 ) = n 1 (n 1 1) n 1 n 0. Bildet man für f(n 1 ) die 1te Ableitung f (n 1 ), so erhält man die Extrempunkte n 11 = n 0 + n 2 0 n 0 n 12 = n 0 n 2 0 n 0. Da D n1 eine nachfolgende Festplatte von D n0 sein muss, fällt n 12 weg und wir müssen nur n 11 betrachten. Durch Einsetzen von n 11 in die 2te Ableitung f (n 1 ) ndet man heraus, dass f(n 1 ) bei n 11 einen Tiefpunkt besitzt. Aus n 11 ergeben sich 2 Werte für n 1 n 1 = 2 n 0 1 und n 1 = 2 n 0. Setzt man die beiden Werte in n 2 n 1 (n 1 1) n 1 n 0 ein, erhält man in beiden Fällen n 2 4 n 0 2. Dies bedeutet, dass jede 2te Verschiebung einer beliebigen Dateneinheit b auf eine Festplatte mit einem mindestens 4 mal so groÿen Index geschieht. Hierdurch erhalten wir, dass ein Block maximal O(log n) mal verschoben werden muss und somit die Laufzeit für den Zugri auf eine Dateneinheit O(log n) beträgt. Cut-and-Paste ist eine Strategie mit einer guten competitiveness und schnellen Laufzeit beim Zugri auf die Daten. Ein Nachteil der Strategie liegt in der Homogenität des Systems. Die Autoren haben versucht Cut-and-Paste mit Hilfe von verschiedenen Leveln, je nach Kapazität und Bandbreite, für heterogene Speichernetzwerke zu erweitern. Diesen Ansatz haben sie allerdings nicht weiter fortgesetzt und stattdessen die Strategien SHARE und SIEVE entwickelt, welche in dieser Ausarbeitung nicht näher beschrieben werden. 7 Zusammenfassung Die immer gröÿer werdende Menge an zu speichernden Informationen verlangt nach exiblen und ezienten Speichersystemen. Einfache Lösungen, welche nur viele Festplatten zusammenfassen, um groÿe Mengen an Kapazität zu gewährleisten, reichen hierfür nicht aus. Speichernetzwerke mit den entsprechenden Strategien zur Verteilung der Daten können diesen Anforderungen jedoch gerecht werden. Sie bieten Möglichkeiten die Daten geschickt innerhalb des Netzwerks zu verteilen, um Datenzugrie schnell abzuarbeiten. Die

172 166 Parallelität des Festplattenarrays wird hierbei zur Performancesteigerung ausgenutzt. Die Strategien sind hierbei je nach Aufgabenbereich zu betrachten und es gibt bislang keine Allround-Lösung, die alle Anforderungen, welche an Speichersysteme gestellt werden, auf einmal abdeckt. Es müssen Kompromisse in Kauf genommen werden. Ein besonders verbreiteter Ansatz ist hierbei RAID, welches breits 1988 vorgestellt wurde und ein kostengünstiges, ausfallsicheres und performantes Verfahren darstellt. Die fehlenden Punkte in RAID wurden in vielen Ansätzen versucht zu lösen, haben allerdings bislang nicht diese starke Verbreitung gefunden. AdaptRaid beschäftigt sich mit dem Problem heterogene Festplatten ezient zu verwalten, während die Cut-And-Paste Strategie versucht möglichst geschickt auf Veränderungen bei der Anzahl der Festplatten im Speichernetzwerk einzugehen. Diese Ansätze sind bislang eher theoretischer Art und lösen ebenfalls nicht alle Anforderungen auf einmal. Nichtsdestotroz stellen Speichernetzwerke eine exible und eziente Möglichkeit zur Verwaltung von Daten dar. Literatur [1] Salzwedel Algorithms for Memory Hierarchies, Seiten , Springer-Verlag Berlin Heidelberg, 2003 [2] Patterson, Gibson, Katz A Case for Redundant Arrays of Inexpensive Disks (RAID), University of California, 1988 [3] Cortes, Labarta A Case for Heterogeneous Disk Arrays, Universitat Politècnica de Catalunya - Barcelona, 2000 [4] Cortes, Labarta Extending Heterogeneity to RAID level 5, Universitat Politècnica de Catalunya - Barcelona, 2001 [5] Zimmermann, Ghandeharizadeh HERA: Heterogeneous extension of raid, In H. Arabnia, editor, Proceedings of the International Conference on Parallel and Distributed Processing Techniques and Applications, volume 4, Seiten , CSREA Press, 2000 [6] Santos, Muntz Using heterogeneous disks on a multimedia storage system with random data allocation, Technical Report , University of California, Los Angeles, Computer Science Department, 1998 [7] Brinkmann, Salzwedel, Scheideler Ecient, Distributed Data Placement Strategies for Storage Area Networks, Paderborn University, 2000

173 167 An Overview of File System Architectures - Multiple Disk Prefetching Strategies for External Merging David Brucholder 1 Einleitung Aktuell gibt es für die Heimanwender sehr schnelle und leistungsstarke Rechner. Teilweise sind diese sogar schneller als der Bedarf dafür ist. Nichtsdestotrotz gibt es aufgrund der Masse an Informationen die durch das weltweite Netz fliessen oder die in Datenbanken verwaltet werden einen immer größeren Bedarf an schnellerer Rechenleistung, Informationslieferung und Auswertung. Obwohl die Rechenleistung heutiger Rechner schon enorm ist, reicht diese in nichtziviler 1 Anwendung teilweise nur unbefriedigend aus. Daher werden verstärkt Techniken gesucht, die Berechnungen schneller leisten. Dafür gibt es verschiedene Ansätze. Zunächst wäre die verstärkte Nutzung von Caches zu nennen. Man muss wissen, der Cache ist wesentlich schneller als irgendeine Festplatte oder auch der Arbeitsspeicher. Beim verstärkten Caching wird versucht, viele Informationen im Cache vorzuhalten und diese dann schneller als durch einen Lese-/Schreibzugriff (Ein-/Ausgabeoperation) liefern zu können. Das funktioniert so lange, wie der Cache die benötigten Daten vorhalten kann. Da liegt auch das Problem beim Caching. Da der Cache in der Regel sehr klein im Verhältnis zum Massenspeicher Festplatte ist, kann er auch nur relativ wenige Daten vorhalten. Ein weiteres Problem für den Cache ist, daß er nicht immer die benötigten Daten vorhalten kann, da er begrenzt ist, und somit gezielt Daten holen muss, was wiederum teure Ein- /Ausgabeoperationen bedeutet. Hier setzt das sogenannte Prefetching ein. Das Prefetching bedeutet das Laden von Daten in den Cache, bevor sie überhaupt benötigt werden. Die Herausforderung an dieser Stelle ist die Technik, die gewährleistet, daß auch die benötigten Daten in den Cache geladen werden und nicht irgendwelche. Eine ganz andere Technik der Leistungssteigerung, oder vielmehr Optimierung der Ausnutzung der vorhandenen Leistung ist die Parallelisierung. Das heißt, es soll mehr als eine Berechnung gleichzeitig durchgeführt werden, oder mehr als eine Datenquelle soll Daten liefern, z.b. in verteilten Datenbanksystemen. Je nach Anwendungsfall ist das eine, das Andere oder beides von Nutzen. In dieser Ausarbeitung soll eine Prefetching-Technik vorgestellt werden, eine Zweite wird 1 zivil Heimanwender

174 168 angerissen, die zum Ziel hat eine möglichst starke Parallelisierung zu ermöglichen sowie die benötigten Daten im Cache vorhalten zu lassen. In Abschnitt 3 werde ich eine grobe Übersicht über das in Abschnitt 2.2 näher erläuterte Modell geben. In Abschnitt 2 werde ich einige mathematische Grundlagen dieser Modelle erklären. Dazu gehört eine Erläuterung von Markov-Ketten in Abschnitt 2.1 sowie Formeln und Definitionen in Abschnitt Anschließend werde ich einen Beweis für die Funktionalität des zufallsbasierten Modells liefern. Im Anschluss daran werde ich eine Analyse des zufallsbasierten Modells in Abschnitt 4 machen sowie anschließend ein paar Ergebnisse präsentieren. Zum Schluss werde ich eine kleine Zusammenfassung in Abschnitt 5 geben. 2 Mathematische Grundlagen In diesem Abschnitt werde ich einige mathematische Grundlagen legen um die später folgende Analyse in Abschnitt 4 zu fundieren. In Abschnitt 2.1 werde ich den Begriff der Markov-Ketten erklären. Im darauf folgenden Abschnitt 2.2 werde ich notwendige Definitionen (2.2.1) aufführen sowie die Transitionsarten (2.2.2) erklären. In Abschnitt werde ich die für das Verständnis notwendigen Lemmata und Theoreme näher erläutern. 2.1 Markov-Ketten Um das Verständnis für die folgenden Berechnungen zu legen erkläre ich hier zunächst den Begriff Markov-Ketten. Markov-Ketten (i.w. MK) sind mathematische/stochastische Modelle. Sie sind eine Verkettung von Zuständen durch sogenannte Übergangswahrscheinlichkeiten. MK sind als Zustandsgraph darstellbar, wie auch in der weiteren Ausarbeitung angenommen. Eine MK besteht aus einer nichtleeren endlichen Menge an Zuständen, dem sog. Zustandsraum. Jeder Zustand hat einen oder mehrere Folgezustände, der mit einer bestimmten Wahrscheinlichkeit gewählt wird. Daraus ist abzuleiten, daß der Folgezustand bzw. die Folgezustände vom aktuellen Zustand abhängig sind. Dies entspricht der Gedächtnislosigkeit des Prozesses. Diesbezüglich kann man Markov-Ketten in Ordnungen einteilen. Eine Markov-Kette oder auch ein Markov-Prozess 1. Ordnung liegt vor, wenn genau der vorherige Zeitpunkt entscheidend für den Folgezustand ist. Eine 2. Ordnung liegt vor, wenn mehr als der vorherige Zeitpunkt berücksichtigt wird, sprich: der vorherige Verlauf ist für den Folgezustand ausschlaggebend. Für die in dieser Ausarbeitung relevanten Modelle liegt eine Markov-Kette 1. Ordnung vor. Das bedeutet, daß ausschließlich der aktuelle Zustand für seinen Nachfolgezustand 2

175 169 relevant ist. 2.2 Formalia Definitionen Hier zunächst ein paar Zeichenerklärungen: Notation Erläuterung C Größe des Caches in Anzahl Blöcke D Anzahl Läufe, synonym für Anzahl Platten im System s = [s 1,..., s j,..., s D ] Ganzzahlvektor mit D Komponenten zur Zustandsdarstellung s j δ(s) = (C D i=1 s i ) γ(s) Definition: Zustand Einzelne Komponente von s Anzahl freier Cache-Blöcke in s Anzahl der Komponenten aus s die 1 sind Ein Cache-Zustand s ist ein Ganzzahlvektor s = [s 1,..., s j,..., s D ], und das s j 1 für 1 j D, γ(s) 1 und 0 δ(s) C D. Das bedeutet, daß der Cache immer mit D Blöcken (je Lauf mindestens 1 Block) gefüllt ist und mindestens eine Komponente aus s, also ein Lauf, 1 ist Transitionsarten Um von einem Zustand zum nächsten zu kommen gibt es vier verschiedenen Transitionsarten. Jeder Transition bestimmt aufgrund ihrer Art und Anwendungswahrscheinlichkeit (entspricht: Übergangswahrscheinlichkeit) den Folgezustand. Im folgenden werden diese Transitionsarten näher erläutert. 1. d-transitionen: Diese Transition steht für eine einfache Leerung (Depletion). Ein Block wird aus Lauf j entfernt, unter den Bedingungen, das alle anderen Läufe mindestens einen Block gefüllt haben (gültiger Zustand), und Lauf j mehr als einen Block gefüllt hat. Formal: s j > 1, Folgezustand t = [s 1,..., s j 1,..., s D ] 2. f-transitionen: Diese Transition wird in dem Fall durchgeführt, wenn ein Lauf j vollständig geleert wird und zusätzlich alle anderen Läufe mindestens einen Block frei haben bezüglich der Definition gültiger Zustände, siehe Abschnitt Dabei wird zunächst der vollständig geleerte Lauf wieder mit einem Block aufgefüllt und anschließend alle anderen Läufe mit je einem Block. f steht dabei für fetch. 3

176 170 Formal: s j = 1 und δ(s) D 1, Folgezustand t = [s 1 + 1,..., s j 1 + 1, 1, s j+1 + 1,..., s D + 1] 3. r-transitionen(replenish): Eine r-transition existiert nur im deterministischen Modell. Sie wird durchgeführt, wenn ein Lauf j vollständig geleert wird, und in der Summe über alle anderen Läufe nicht alle oder keiner einen Block frei haben. Dann wird lediglich der geleerte Lauf wieder gefüllt. Das entspricht der Situation, das der Vorzustand gleich dem Endzustand ist. Formal: s j = 1, δ(s) < D 1, und Folgezustand t = s 4. p-transitionen(partial Fetch): Eine p-transition kommt nur im zufallsbasierten Modell vor. Sie wird durchgeführt, wenn ein Lauf j vollständig geleert wird und in der Summe über alle anderen Läufe nicht alle oder keiner einen Block frei haben. Dann wird aus den übrigen Läufen, die einen Block frei haben zufällig einer gewählt, der ebenfalls einen neuen Block gefüllt bekommt. Im ungünstigsten Fall (δ(s) = 0) wird kein weiterer Block gefüllt. Damit entspräche diese spezielle p-transition der r-transition im deterministischen Modell. Formal: s j = 1, δ(s) < D Lemmata und Theoreme Ich werde in diesem Abschnitt ein paar mathematische Umformungsregeln aufführen. Teilweise werde ich auf Beweise in [2] verweisen, da hier eine vollständige Beweiskette den Rahmen dieser Ausarbeitung sprengen würde. Bevor die folgenden Lemmata eingeführt werden, müssen zwei Begriffe definiert werden: 1. Partition und 2. Komposition. Definition: Partition Eine Partition ist die Bezeichnung für eine Zahl n Z + für die eine Sequenz von Zahlen λ 1, λ 2,..., λ m Z + existieren, die aufsummiert n ergeben. Formal: m i=1 λ i = n. Die λ i werden auch Teile der Partition genannt. Definition: Komposition Eine Komposition ist eine Partition, in der der Grad der Summanden berücksichtigt wird. Eine Komposition mit m Teilen wird wie folgt geschrieben: [c 1, c 2,..., c m ], wobei die c i Teile der Komposition sind und zusätzlich gilt: c i 1 für 1 i m. Lemma 1a:[[1],p.54] 4

177 c(m, n) = 171 n 1 m 1 Dieser Wert ist die Anzahl von möglichen Kompositionen von n mit m Teilen. Lemma 1b:[[1],p.63] c i (m, n) = n m(i 1) 1 m 1 Dieser Wert ist die Anzahl von Kompositionen von n mit m Teilen, wobei alle Teile größer oder gleich i sind. Daraus läßt sich das folgende Lemma ableiten Lemma 2: Die Anzahl der Kompositionen von n durch m Teile, wobei exakt k Teile gleich 1 sind ist gegeben durch: m k 1 wenn k = m = n n m 1 sonst m k 1 Definition: Γ m (n) Γ m (n) ist die Menge aller Kompositionen von n mit m Teilen, in denen mindestens ein Teil gleich 1 ist. Γ m (n) = s = [s 1,..., s m ] m k=1 s k = n, s k 1, γ(s) 1. Lemma 3: Γ m (n) = n 1 m 1 n m 1 m 1 Das bedeutet, daß die Anzahl der Komponenten in Γ m (n) die Differenz zwischen der Anzahl der Kompositionen von n mit m Teilen und der Anzahl der Kompositionen von n mit m Teilen ist, bei denen jeder Teil größer oder gleich 2 ist. 5

178 172 Lemma 4: s Γ γ(s) = m j 2 m(j) m 2 Das ist die Anzahl der Teile der aus der Menge Γ m (j) die 1 sind, aus allen Kompositionen. Lemma 5: Ψ m (n) = n m n m m Das ist die Anzahl aller Zustände, die die Bedingungen aus der Definition für Zustände erfüllen. Lemma 6: s Ψ γ(s) = m n 1 m(n) m 1 Das ist die absolute Anzahl von Teilen aus der Menge Ψ m (n) die gleich 1 sind. Anstatt die Beweise für die o.g. Lemmata und Definitionen hier aufzuführen, verweise ich für die Definitionen von Partition und Komposition auf [1], Seite 1 und Seite 54. Ebenso für die Beweise der Lemmata 1a und 1b verweise ich auf [1] Seite 54 und 63. Die Beweise für die anderen Lemmata und Definitionen sind in [3] auf den Seiten 7 bis 11 zu finden. 3 Prefetching-Modelle Es existieren verschiedene Prefetching-Techniken. Aus dem Bereich der One-Block-Ahead- Techniken gibt es einfache Techniken, die je nach Modell einen Block im Voraus lesen. Aus dem Bereich der Victim Caches werden z.b. die Cache Misses zusätzlich berücksichtigt. Es gibt jedoch noch einige weitere Modelle. Die beiden Modelle die ich im folgenden Teil vorstellen werde basieren auf einer anderen Art des Pretching. Die Grundlegende Idee bei diesen Modellen ist die Parallelisierung von Ein-/Ausgabeoperationen um eine Geschwindigkeitssteigerung zu erlangen. Am Beispiel 6

179 173 des External Mergesort möchte ich die Funktionsweise vorstellen. Zugrunde liegt ein Mehrplattensystem. Auf jeder Platte dieses Systems liegt Blockweise eine sortierte Folge von Zahlen vor. Im weiteren sollen diese Zahlen plattenübergreifend sortiert werden. Der Cache hat unbegrenzte Größe und eine feste Anzahl von Läufen (Runs), wobei die Anzahl der Läufe der Anzahl der Platten im System entsprechen. Ein Block enthält eine sortierte Folge von Zahlen, die sortiert sind. In den Cache werden in jeden Lauf je ein Block von je einer Platte eingelesen. Dann werden aus den eingelesenen Blöcken aus dem Cache die Zahlen ausgelesen und übergreifend sortiert. Leert sich dabei ein Block, wird je nach Zustand eine bestimmte Transition des erneuten Block-Einlesens erfolgen, oder nicht. Bevor ich näher auf die beiden Prefetching-Modelle eingehe, ist es wichtig zu wissen, das ein Zustandsgraph/Markov-Kette 2 aufgebaut wird, in dem von einem zum nächsten Zustand mit verschiedenen Transitionen (mit Übergangswahrscheinlichkeiten) gewechselt werden kann. In Abschnitt ist die Definition eines gültigen Zustands gegeben. Wichtig ist hierbei zu verstehen, daß jede Transition, die ein Nachfüllen eines Blocks nach sich zieht, eine Ein-/Ausgabeoperation bedeutet und somit einem teueren Lese- /Schreibzugriff entspricht. Lediglich eine einfache Leerung durch eine d-transition hat keine Ein-/Ausgabeoperationskosten. Das einfache Auffüllen des geleerten Blocks j bei f- und p-transitionen ist eine Demand-Fetch-Operation. Eine f- oder p-transition bedeutet je nach Zustand zusätzliche Prefetch-Operation(en) auf allen anderen Läufen, die nicht geleert wurden, bzw. nur einem zusätzlichen Lauf. 4 Analyse des randomisierten Prefetching-Modells An dieser Stelle möchte ich einen Begriff einführen, der für das weitere Verständnis der Analyse notwendig ist. Ergodizität bezeichnet in unserem Fall die Tatsache, daß es eine einmalige und begrenzte Verteilung von Wahrscheinlichkeiten gibt in einem bestimmten Zustand in der Markov-Kette zu sein, die Unabhängig vom Initialzustand [1,...,1,...,1] ist. Ergodizität liegt vor, wenn die Markov-Kette minimal, wiederkehrend Nicht-Null und aperiodisch ist. Minimalität in einer Markov-Kette haben wir dann, wenn es von jedem Zustand einen Transitionspfad zu jedem anderen Zustand gibt. Die Eigenschaft wiederkehrend Nicht-Null zu sein, liegt dann vor, wenn die mittlere Zeit zur Rückkehr von einem Zustand zu sich selbst endlich ist. Die letzte Eigenschaft, die Aperiodizität hat eine Markov-Kette 2 siehe 2.1 7

180 174 dann, wenn ein Zustand nach beliebig vielen Schritten größer gleich einer Konstanten k zurückkehren kann. Das ist dann möglich, wenn die Länge des Transitionspfades aufgrund z.b. von p-transitionen variieren kann, wobei ein Zustand s auf sich selbst führt (z.b. bei 0 weiteren freien Cache-Blöcken, siehe auch Definition von p-transition). Durch die Eigenschaft der Ergodizität können wir also festhalten, daß es für die Zustände im Dauerzustand eine Gleichgewichtswahrscheinlichkeit gibt. Wie später auch noch gezeigt wird, haben alle Zustände in der Markov-Kette dieser Modelle die gleichen Wahrscheinlichkeiten. 4.1 Zielsetzung Das Ziel der beiden Prefetchingmodelle, wobei der Fokus wie schon gesagt, beim zufallsbasierten Modell liegt, war eine verstärkte Parallelisierung bezüglich der Fetches von Blöcken zu erlangen. Diese verstärkte Parallelisierung möchte ich nun im folgenden durch den Beweis der durchschnittlichen Parallelität des zufallsbasierten Modells zeigen. Für das zufallsbasierte Modell gilt: Lemma 7: Jeder Zustand der der Definition für Zustände entspricht, kann vom Initialzustand [1,...,1,...,1] erreicht werden. Der Beweis dazu steht in [3] auf Seite 11/12. Jeder Zustand kann vom Startzustand erreicht werden. Die Anzahl der Zustände in der Markov-Kette ist C C D. D D Der Beweis dafür ist in [3] Seite 12. Die Ergodizität für die Markov-Ketten für das randomisierte Modell wird dadurch gezeigt, daß Minimalität, Nicht-Null-Wahrscheinlichkeit und Aperiodizität vorliegen. Die Minimalität zeige ich dadurch, daß zunächst von einem Startzustand s durch eine Sequenz von d-transitionen zum Initialzustand [1,...,1] gegangen wird. Dann wird der Pfad vom Initialzustand zu einem Zustand t hinzugefügt, der nach Lemma 7 immer erreichbar ist. Diese Markov-Kette ist minimal. Jeder Zustand s hat eine begrenzte Nicht-Null-Wahrscheinlichkeit, daß zu ihm zurückgekehrt wird, da es einen endlich langen und nichtleeren Pfad von s zu sich selbst gibt, und alle Zustandstransitionen eine begrenzte Nicht-Null-Wahrscheinlichkeit haben. Diese Markov-Kette ist wiederkehrend Nicht-Null. Die letzte Eigenschaft, die Aperiodizität, zeige ich dadurch das es einen Transitionspfad 8

181 175 gibt, der von einem Zustand s über einen anderen Zustand t mit 0 freien Blöcken führt. Von diesem Zustand t kann durch eine beliebig oft wiederholte p-transition der Transitionspfad zu s beliebig verlängert werden, und somit liegt keine Periodizität vor. Diese Markov-Kette ist aperiodisch. Damit ist die Ergodizität der Markov-Kette für das randomisierte Modell gezeigt. Für den vollständigen Beweis verweise ich auf [3] Seite Um zu zeigen das jeder Zustand die gleiche Wahrscheinlichkeit besitzt, muß der Beweis erbracht werden, daß jeder Zustand mit der Wahrscheinlichkeit C C D D D 1 in der Summe aller Wahrscheinlichkeiten der Eingangstransitionen errreicht wird. Dazu müssen zunächst drei Fälle geprüft werden. 1. δ(s) > 0 und 2 γ(s) D 2. δ(s) > 0 und γ(s) = 1 3. δ(s) = 0 und γ(s) = D k Der erste Fall bedeutet, daß der Cache nicht voll ist und mindestens zwei Läufe nur einen Block beinhalten. Dieser Zustand kann nur durch eine d-transition erreicht werden. Es gibt maximal D Vorzustände. Von jedem dieser Vorzustände kann mit Wahrschinlichkeit 1/D in den Endzustand gewechselt werden. Daraus folgt: D(1/D) = 1 Der zweite Fall bedeutet, daß ein nichtvoller Cache-Zustand vorliegt, in dem genau ein Lauf nur mit einem Block gefüllt ist. Dieser Cache-Zustand kann durch f- oder d-transitionen erreicht werden. Da ein Lauf genau einen Block gefüllt hat, bleiben D-1 Möglichkeiten für eine d-transition oder eine Möglichkeit für eine f-transition übrig. (1/D) + (D 1)/(1/D) = 1 Im dritten Fall ist der Cache voll und D-k (k=max. D-1) Läufe haben genau einen Block gefüllt. Der Zustand kann nur durch f- oder p-transitionen erreicht werden, da diese beiden Transitionsarten als einzige den Cache füllen. D k D kj=0 k j D 1 j Für die vollständigen Beweise der o.g. drei Fälle möchte ich den interessierten Leser auf [3] Seite 13 und 14 verweisen. 9

182 176 Damit ist nun zunächst die Gleichwahrscheinlichkeit aller Zustände gezeigt. Im Folgenden Teil kann nun die durchschnittlich gefetchte Anzahl von Blöcken durch eine Ein- C C D D D /Ausgabeoperation berechnet werden, die beträgt. Der Beweis dafür C 1 D 1 ist in [3] Seite 14 und 15 zu finden. Wir suchen nun eine geschlossene Form für die Binomialkoeffizienten. Zunächst der Nenner. Nach Lemma 6 beträgt dieser D C 1. D 1 Für den Zähler wird schließlich die Anzahl von Teilen die gleich 1 sind über aller Zustände mit C-j freien Blöcken berechnet. Die Anzahl beträgt nach Lemma 4 D j 2. D 2 Zusätzlich müssen noch die gefetchten Blöcke berücksichtigt werden. In der Summe kommen wir dabei auf folgende Formel: Cj=D (C j + 1)D C D j=d (C j D + 1)D D 2 D 2 Durch Vereinfachungen und Ersetzungen kann schließlich für den Zähler folgende Wert ermittelt werden: D [ C D C D ] D Die vollständige Umformung ist in [3] Seite 15. Jetzt werden Zähler und Nenner zusammengeführt: [ C C D ] C C D D D D D D C 1 C 1 D D 1 D 1 q.e.d. Zusammengefaßt bedeutet dies, daß alle Zustände die gleiche Dauerzustandswahrscheinlichkeit besitzen und dadurch die durchschnittliche Anzahl gefetchter Blöcke durch Ein- /Ausgabeoperationen den oben gezeigten Wert beträgt. 10

183 177 5 Zusammenfassung Die berechneten Ergebnisse sowie die simulierten Ergebnisse sind in [3] zu finden. Was sehr interessant ist, ist die Tatsache, daß das zufallsbasierte Modell rechnerisch schlechter abschneidet als das deterministische Modell, da dort eigentlich der konkurrierende Zugriff besonders gut zum Tragen kommen müßte. In der Simulation allerdings hat sich gezeigt, daß beide Modelle ähnlich gut sind und kaum Unterschiede bestehen. Abschließend kann ich sagen, daß die beiden Modelle (auch wenn in dieser Arbeit nur das randomisierte Verfahren gezeigt wurde), das zufallsbasierte wie auch das deterministische Verfahren, sehr interessante Möglichkeiten sind, ein Prefetching durchzuführen. Literatur [1] G.E. Andrews. The theory of partitions. Addison Wesley. [2] O. Patashnik R.L. Graham, D.E. Knuth. Concrete mathematics. Addison Wesley. [3] Peter J. Varman Vinay Sadananda Pai, Alejandro A. Schäffer. Markov analysis of multiple-disk prefetching strategies for external merging

184 178

185 179 Exploitation of the Memory Hierarchy in Relational DBMSs Michael Blank 1 Einleitung Effizienz ist eine grundlegende Anforderung an Softwaresysteme, die große Datenbestände verwalten und komplexe Funktion darauf anbieten. Mit der Speicherhierarchie heutiger Computer wird versucht Lokalität von Daten und Instruktionen auszunutzen, um die Entfernung und die Diskrepanz der Zugriffszeiten zwischen dem Prozessor und den einzelnen Speicherstufen zu reduzieren. Lokalität ist jedoch keine geschenkte Eigenschaft von Applikationen; komplexe Daten- und Programmstrukturen oder schlechte Programmierung verhindern allzu oft die Lokalitätseigenschaft. Folglich müssen Lokalitätskriterien bereits beim Programmentwurf beachtet werden. Im Rahmen dieser Seminarausarbeitung werden aufbauend auf [UM03] Algorithmen für Datenbank Managment Systeme (DBMSs) untersucht. Der Fokus der Algorithmen liegt darin, die zeitliche Lokalität für wiederkehrende Daten und die räumliche Lokalität für zusammengehörige Daten auszunutzen, um die Anzahl der Datentransfers von einer Speicherstufe zu einer zum Prozessor näheren Stufe zu reduzieren und damit die Anfrageausführung zu beschleunigen. In DBMSs können Lokalitätskriterien auf unterschiedliche Systemschichten angewandt werden. Diese Ausarbeitung konzentriert sich nur auf die grundlegenden Komponenten, insbesondere auf Algorithmen zur Join-Berechnung und auf effiziente Indexstrukturen. 2 Grundlagen Zum Verstehen dieser Ausarbeitung werden vom Leser Grundkenntnisse über relationale Datenbanken und deren Aufgaben vorausgesetzt. Dieses Kapitel soll nur einen kurzen Überblick geben und für die folgenden Analysen die grundlegenden Definitionen einführen. In DBMSs werden Daten in Relationen in Form von zweidimensionalen Tabellen verwaltet. Eine Zeile (Tupel) einer Relation R, mit t R, repräsentiert eine Entität; eine Spalte der Tabelle entspricht einem Attribut der Entität. Die Kardinalität einer Relation R, die R abkürzt, entspricht der Anzahl der Zeilen darin. Je nach Größe werden die Tupel derselben Relation zu mehreren in einem Block auf der Festplatte gespeichert. Im Folgenden wird angenommen, dass B Tupel auf einen Festplattenblock passen. Auf die einzelnen gespeicherten Tupeln kann entweder sequentiell über alle Blöcke oder über einen Index zugegriffen werden. Ein Index

186 180 indiziert eine Relation R über ein Attribut R.a, um bei Punkt- wie auch bei Bereichsanfragen über R.a schnell die Blöcke mit den gesuchten Tupeln auf der Festplatte zu identifizieren. Mit Hilfe der relationalen Algebra lassen sich Anfragen, in den meisten DBMSs als SQL 1, formulieren, um Relationen miteinander zu verknüpfen oder zu reduzieren und komplexere Informationen daraus herzuleiten. Die Selektion und der Join stellen für die weitere Betrachtung einen Auszug der Operatoren dieser Sprache dar. Bei der Selektion legt eine Bedingung eine Auswahl von Tupeln fest, die in die Ergebnismenge aufgenommen werden sollen. Eine Selektion auf einer Relationen R wird durch σ Bedingung (R) := {t R t erfüllt Bedingung } formalisiert. Ein Join R S von zwei Relationen R und S bezeichnet eine Selektion mit der Join-Bedingung, die auf dem kartesischen Produkt 2 R S ausgeführt wird, also R Bedingung S := σ Bedingung (R S). Ein Equi-Join ist ein Spezialfall des Joins mit der Join-Bedingung, dass der Inhalt bestimmter Spalten identisch sein muss. Ein Equi-Join über eine Relation R und S bei dem die Attribute R.a und S.b gleich sein müssen, wird mit R a b S abgekürzt. Eine Anfrage durchläuft in einem DBMS mehrere Komponenten, die jeweils eine spezielle Aufgabe erfüllen; eine typische Umsetzung ist in Abbildung 1 dargestellt. Abbildung 1: Struktur der Anfrageausführung in einem DBMS Im ersten Schritt wird die gestellte Anfrage geparst und vom Rewriter in eine Normalform transformiert, die der Optimizer unterschiedlichen Optimierungen unterzieht. Das Ergebnis der logischen Anfrageoptimierung ist ein sogenannter Anfragebaum (Abbildung 2), eine Datenstruktur, die den Datenfluss und die relationalen Operatoren äquivalent zur Ausgangsanfrage beschreibt. Jeder Knoten im Anfragebaum steht für einen Operator mit seiner Implementierung. Liegen für denselben Operator unterschiedliche Implementierungen vor, wählt der Optimizer anhand von Metriken die aus, die am meisten Effizienz verspricht. Der Executor ist letztlich für die Ausführung des Anfragebaums zuständig. Beginnend am Wurzelknoten werden die Operatorenknoten Tupel für Tupel ausgeführt. Benötigt dabei ein 1 Structured Query Language ist eine deklarative Datenbanksprache für relationale Datenbanken und stellt eine Reihe von Befehlen zur Definition von Datenstrukturen nach der relationalen Algebra. 2 Das Kartesische Produkt R S ist eine Operation sehr ähnlich dem kartesischen Produkt aus der Mengenlehre. Das Resultat ist die Menge aller Kombinationen der Tupel aus R und S.

187 181 Knoten weitere Eingabetupel, werden sie von seinen Kindknoten nachgeholt. Dieser Vorgang wiederholt sich bis zu den Blättern des Anfragebaums, wo die Tupel sequentiell oder über einen Index von der Festplatte eingelesen werden. Diese Art der Datenverarbeitung wird Pipeline-Ausführung genannt, sie verspricht neben des geringen Speicherbedarfs für Zwischenergebnisse auch eine schnelle Auslieferung der ersten Endergebnisse. Die Komponente Access Methods abstrahiert vom Executor die physische Organisation der Tupel auf der Festplatte und den Zugriff darauf. Das Puffern von bereits eingelesenen Tupeln erledigt der Buffer Manager transparent für die darüberliegenden Komponenten. Der tatsächliche Zugriff auf die Festplattenblöcke erfolgt vom Storage Manager. Die in den Abbildungen 1 und 2 farblich hervorgehobenen Komponenten kennzeichnen Bereiche, für die im Folgenden Algorithmen vorgestellt werden. Das Kapitel 3 wird unterschiedliche Implementierungen des Join-Operators als Teil vom Executor und Kapitel 4 den indexierten Zugriff auf Tupeln als Aufgabe der Komponente Access Methods vorstellen. Abbildung 2: Anfragebaum 3 Join-Algorithmen Für den Join-Operator existiert eine Vielzahl von Implementierungen, die abhängig vom Szenario in Einsatz kommen. Weil die vom Join zu verarbeitenden Daten nur selten komplett in den Arbeitspeicher oder in den Cache passen, müssen sie wiederkehrend von der entsprechend tieferen Speicherhierarchieebene nachgeladen werden. Daher stellt das aufkommende Datentransfervolumen einen Flaschenhals dieser Algorithmen dar. Eine Abhilfe versprechen Entwurfstechniken, die die Lokalität von Daten ausnutzen. Durch das Blocken von Daten kann zum Beispiel im Nested Loop Join die Anzahl der Datentransfers intern, zwischen Hauptspeicher und Cache, und extern, zwischen Festplatte und Hauptspeicher, reduziert werden. Hier werden Tupeln blockweise verarbeitet, um soweit möglich die zeitliche Lokalität der im Block eingelesenen Tupeln auszunutzen. Bei den Hash Joins wird die Technik der horizontalen Partitionierung von Daten angewandt. Hier werden Tupeln nach einem bestimmten Muster in mehrere Teilmengen verteilt und der Join anschließend nur zwischen zugehörigen Paaren dieser Teilmengen gebildet. Der Vorteil dieser Methode liegt darin, dass nach der Partitionierung die einzelnen Teilmengen im externen Fall nur einmal von Festplatte in den Hauptspeicher eingelesenen werden müssen.

188 Nested Loop Join Bei der Berechnung eines Joins über eine Relation R und S mit einer beliebigen Join-Bedingung R S verfolgt der Nested Loop Join Algorithmus [PM92] die einfache Idee, für jedes Tupel t R R und jedes t S R die Join-Bedingung zu überprüfen. Dazu wird für jedes Tupel der äußeren Relation R aus Listing 1 die innere Relation S jedes Mal komplett traversiert, um die passenden Join-Paare zu finden. Erwartungsgemäß beträgt die Laufzeit des Nested Loop Joins O( R S ). Ausreichend ist diese Laufzeit insbesondere bei Nicht-Equi-Joins mit komplizierten Bedingungen, die andere Join-Algorithmen unter Umständen nicht beantworten könnten. Im Wesentlichen wird die Laufzeit jedoch von der Anzahl der externen und der internen Datenzugriffen innerhalb der Speicherhierarchie dominiert. Im externen Fall, unter der Annahme, dass mit dem sequentiellen Scan das Einlesen eines Festplattenblocks mit B Tupeln genau eine I/O-Operation benötigt, interessiert uns die Frage, wie oft Blöcke von der Festplatte in den Hauptspeicher geladen werden müssen. Weil für die äußere Relation R S B Blöcke und für jedes Tupel davon die innere Relation komplett mit B Blöcken eingelesen werden müssen, führt der Nested Loop Join insgesamt R S B + R B I/O- Operationen aus. Im internen Fall wird dagegen die Anzahl der Zugriffe auf den Hauptspeicher betrachtet, um Tupeln in den Cache für die Abarbeitung durch die CPU zu transferieren. Der Nested Loop Join benötigt hier R Zugriffe für die äußere Relation und für jedes Tupel davon jeweils S Zugriffe auf alle Tupel der inneren Relation, weil das sequentielle Zugriffsmuster auf S die Wiederverwendung bereits gecachter Tupel verhindert. So werden insgesamt R + R S Tupeltransfers vom Hauptspeicher in den Cache verursacht. In beiden Fällen verzeichnet der Algorithmus eine hohe Zugriffslast auf die jeweils nächstniedrigere Speicherebene der Speicherhierarchie, weil unter anderem die zeitliche Lokalität von Daten kaum ausgenutzt wird. Die nachfolgenden Abschnitte behandeln Erweiterungen des Nested Loop Joins, die durch das Blocken von Daten das externe wie auch das interne Transfervolumen reduzieren. 1 Res := {}; 2 for each t R in R 3 for each t S in S Listing 1: Einfacher Nested Loop Join Algorithmus 4 if Join - Bedingung erfüllt (t R, t S ) then 5 Res := Res (t R t S ); Externer Blocked Nested Loop Join Ein wesentlicher Nachteil im I/O-Verhalten des Nested Loop Joins besteht in der fehlenden Wiederverwendung von bereits von Festplatte eingeladenen Tupeln. Auch ein womöglich großer zur Verfügung stehender Hauptspeicher wird nicht ausgenutzt. Mit dem externen Blocked Nested Loop Join [PM92] werden diese Schwächen weitgehend behoben. Die Idee des Algorithmuses liegt darin, für möglichst große in den Hauptspeicher eingelesene Fragmente der äußeren Relation die innere Relation nur einmal zu traversieren. Das Blocken

189 183 der äußeren Relation steigert so die zeitliche Lokalität der gelesenen Tupel von R und reduziert die Anzahl der Zugriffe auf die innere Relation S. Vor der Ausführung des externen Blocked Nested Loop Joins aus Listing 2 wird zuerst der zur Verfügung stehender Hauptspeicher aufgeteilt. Zum Einlesen der äußeren Relation R wird möglichst viel Platz reserviert, es wird angenommen, dass M Festplattenblöcke mit B Tupeln darin in den Hauptspeicherblock b R passen. Der restliche Hauptspeicher muss für einen Festplattenblock der inneren Relation S und für die Ergebnisse der Join-Berechnung ausreichen. Die Ausführung des Joins erbringen die vier ineinander verschachtelten Schleifen. In den beiden äußeren Schleifen (Zeile 2-3) werden M Blöcke von R eingelesen und dafür jeweils nur einmal S traversiert. Den Join berechnen die beiden inneren Schleifen (Zeile 4-7) zwischen den im Hauptspeicher befindenden Tupeln t R b R und t S b S nach dem bereits kennengelernten Nested Loop Join Verfahren. Die Laufzeit des Algorithmuses ist gleich der des einfachen Nested Loop Joins, es werden jedoch deutlich weniger I/O-Operationen benötigt. Das Blocken der äußeren Relation erfordert in dieser Variante die Relation S nur noch R MB verursachter Festplattenzugriffe beträgt somit R B 1 Res := {}; + R MB mal zu traversieren. Die Gesamtanzahl S B. Listing 2: Externer Blocked Nested Loop Join Algorithmus 2 for each block b R of R /* b R = MB */ 3 for each block b S of S /* b S = B */ 4 for each t R in b R 5 for each t S in b S 6 if Join - Bedingung erfüllt (t R, t S ) then 7 Res := Res (t R t S ); Interner Blocked Nested Loop Join Interne Join-Verfahren konzentrieren sich auf die Berechnung des Joins auf Teildaten, die bereits in den Hauptspeicher mittels externer Verfahren transferiert sind. Hier ist das Ziel, die Datenlokalität im Cache zu erhöhen, um die Anzahl der Zugriffe auf den zu cachenden Hauptspeicher zu verringern. Im einfachen Nested Loop Join aus Listing 1 rührt das schlechte Cache-Verhalten vom sequentiellen Zugriff auf die innere Relation S her. Jeder Zugriff auf ein Tupel aus S erzeugt unter Umständen einen Cache-Miss, weil die sequentiell eingelesenen Daten nicht so schnell wiederverwendet werden können, wie sie aus dem Cache wieder verdrängt werden. Der interne Blocked Nested Loop Join [AS94] wirkt durch das Blocken der inneren Relation S diesem Problem entgegen. Die Idee ist, die Relation S für jedes Tupel aus R nicht mehr sequentiell, sondern blockweise zu traversieren, um auf diese Weise für die in einen Cache-Block eingelagerten S-Tupel die Lokalität und die Hit-Rate zu verbessern. Dazu teilt der Algorithmus aus Listing 3 die Relation S zuerst in B c große Blöcke auf. Es wird im Folgenden angenommen, dass unter einer gegebenen Architektur auf einen Cache-Block B c viele Tupeln 3 von S passen. 3 Zur Vereinfachung wird hier die Einlagerung von ganzen Tupeln in den Cache betrachtet. In praktischen Anwendungen werden für den Join nicht relevante Daten extrahiert, um eine möglichst hohe Cache-Lokalität zu erzielen.

190 184 So erfolgt für jeden Block b S (äußere Schleife in Zeile 2) die Berechnung des Joins mit der einfachen Nested Loop Variante (beide inneren Schleifen in Zeile 3-6) zwischen den gecachten Tupeln aus b S und ganz R. Die Reorganisation des Zugriffsmusters auf die innere Relation S verspricht für einen aus dem Hauptspeicher in den Cache geladenen Block b S einen hohen Grad an Wiederverwendung ohne Verdrängung und eine erneute Einlagerung. Folglich muss auf alle Tupeln von S nur einmal im Hauptspeicher zugegriffen werden. Dafür resultiert jedoch ein höherer Zugriff auf Tupeln der äußeren Relation R, weil diese komplett für jeden Block b S, wovon es jedoch nur S B c gibt, gelesen wird. Mit insgesamt S B c R + S Zugriffen auf Tupeln im Hauptspeicher verbessert der interne Blocked Nested Loop Join sein Cache-Verhalten im Vergleich zum einfachen Algorithmus. Praktische Untersuchungen in [AS94] ergaben eine Beschleunigung der Join-Berechnung um bis zu 250%. 1 Res := {}; Listing 3: Interner Blocked Nested Loop Join Algorithmus 2 for each block b S of S /* b S = B c */ 3 for each t R in R 4 for each t S in b S 5 if Join - Bedingung erfüllt (t R, t S ) then 6 Res := Res (t R t S ); 3.2 Hash Join Hash Joins gehören zu den effizientesten Algorithmen des Join-Operators. In ihrer Wirkung sind sie jedoch nur auf den Equi-Join R a b S mit der Bedingung, dass der Inhalt bestimmter Attribute R.a und S.b identisch sein muss, beschränkt. Genau diese Einschränkung verleiht dem Hash Join jedoch seine Stärke. In den nachfolgenden Abschnitten wird der Fokus auf externen Hash Join Algorithmen liegen, die das Aufkommen von wiederholenden Zugriffen auf die Festplatte optimieren, weil keine der zu joinenden Relation auf einmal in den Hauptspeicher passen. Vorgestellt werden der Grace und der Hybrid Hash Join Grace Hash Join Der Grace Hash Join [MK83] ist ein typischer Divide & Conquer Algorithmus, der die Eingabedaten zuerst in disjunkte Teilmengen zerlegt und sie anschließend einzeln der Join- Berechnung unterzieht. Der Algorithmus aus Listing 4 zur Berechnung des Equi-Joins geht dabei in zwei Phasen vor. In der Build-Phase (Zeile 2-8) werden Tupeln der sogenannten Build-Relation R und der Probe- Relation S durch eine Hash-Funktion h über die Join-Attribute in Partitionen PR h(r.a) und PS h(s.b) eingeteilt, so dass jede Partition PR i der Probe-Relation in den Hauptspeicher passt. Die einzelnen Partitionen werden dabei extern auf der Festplatte zwischengespeichert. Definition 1. Zwei Partitionen PR i R und PS j S heißen hash-identisch, wenn i = j gilt, d.h. alle Tupeln darin über denselben Hash-Schlüssel verfügen.

191 185 Satz 1. Wenn für ein Tupel t R R mit t R PR i und ein t S S mit t S PS j die Gleichheit über die Join-Attribute t R.a = t S.b gilt, dann sind PR i und PS j mit i = j hash-identisch. Beweis. Weil bei Gleichheit von t R.a und t S.b ihre Hash-Schlüssel h(t R.a) und h(t S.a), die die Zuordnung zu entsprechenden Partitionen festlegen, ebenfalls identisch sind, folgt der Satz. Der Satz 1 liefert eine notwendige Bedingung für die Berechnung von passenden Join-Tupeln zwischen R und S. Demnach können nur Tupel aus den hash-identischen Partitionen PR i und PS j mit i = j die Join-Bedingung erfüllen. Der untenstehende Algorithmus arbeitet die zu einander passenden Partitionen in der Probe-Phase (Zeile 10-15) schrittweise ab, indem zuerst für die jeweils nächste Partition PR i, die komplett in Hauptspeicher passt, eine Hash-Tabelle (Zeile 12) über die Werte PR i.a aufgestellt wird. Anschließend (Zeile 13-15) werden die eingelesenen Tupel der hash-identischen Partition PS i sequentiell gegen die Schlüssel in der Hash- Tabelle geprüft, um übereinstimmende Join-Partner zu bestimmen. Weil der vorgestellte Grace Hash Join im Gegensatz zum Nested Loop Join keine verschachtelten Abläufe zwischen den Relationen aufweist und die Abarbeitung der Tupel einer Relation von den Tupeln der anderen sauber trennt, beträgt die Laufzeit des Algorithmuses O( R + S ). Unter Annahme einer Gleichverteilung der Join-Attribute und einer perfekte Hash-Funktion h für die Partitionierung, sowie, dass pro I/O-Zugriff auf die Festplatte gleichzeitig B Tupeln gelesen bzw. geschrieben werden, benötigt die Build-Phase R Lese-Zugriffe und genauso viele Schreib-Zugriffe, um die Partitionen zwischenzuspeichern. In der anschließenden Probe-Phase kostet das Einlesen der Partitionen von R wieder nur R B I/O-Operationen und, weil jedes S-Tupel nur einmal gegen die Hash-Tabelle geprüft werden muss, zusätzlich noch S B Join Algorithmus insgesamt 3 R B 1 Res := {}; 2 /* Build - Phase */ 3 for each t R in R do 4 i := h(t R.a); 5 PR i := PR i t R ; 6 for each t S in S do 7 j := h(t S.b); 8 PS j := PS j t S ; 9 10 /* Probe - Phase */ 11 for i := 1 to N do B + S B Zugriffe auf die Partitionen von S. Somit verursacht der Grace Hash + 3 S B I/O-Operationen. Listing 4: Grace Hash Join Algorithmus für Equi-Join R a b S 12 Lese PR i ein und erzeuge Hash - Tabelle über t R.a mit t R PR i 13 for each t S in PS i do 14 for each t R in Hash - Tabelle mit Schlüssel t S.b do 15 Res := Res (t R t S );

192 Hybrid Hash Join Der Hybrid Hash Join[Dd84] ist eine Erweiterung vom Grace Hash Join, der das I/O- Verhalten weiter verbessert. Hier wird der Nachteil behoben, dass ein womöglich großer zur Verfügung stehender Hauptspeicher während der Build-Phase nicht ausgenutzt wird. Vollständigkeitshalber ist der Algorithmus in Listing 5 angegeben. Die Idee von Hybrid Hash Join liegt darin, die Probe-Phase für die Tupeln der ersten Partition, die im Hauptspeicher ohne zwischenzuspeichern Platz haben, in die Build-Phase vorzuziehen. Dazu wird bereits während der Partitionierung von R eine Hash-Tabelle über die der Partition PR 1 zugeordneten Tupeln (Zeile 6-7) aufgestellt. Bei der anschließenden Partitionierung von S können die der Partition PS 1 eingeteilten Tupeln (Zeile 12-14) sofort der Join- Prüfung über die Hash-Tabelle unterzogen werden. Alle anderen Partitionen werden analog zum Grace Hash Join auf der Festplatte gepuffert und in der Probe-Phase paarweise verarbeitet. Der Hybrid Hash Join verbessert bei gleicher Laufzeit sein I/O-Verhalten im Vergleich zum Grace Hash Join um 2 PR 1 B + 2 PS 1 B I/O-Operation, die sonst zum Puffern und Wiedereinlesen der ersten Partitionen benötigt wären. 1 Res := {}; Listing 5: Hybrid Hash Join Algorithmus für Equi-Join R a b S 2 Erzeuge eine leere Hash - Tabelle 3 /* Build - Phase */ 4 for each t R in R do 5 i := h(t R.a); 6 if i = 1 then 7 Ergänze Hash - Tabelle um t R mit dem Schlüssel t R.a 8 else 9 PR i := PR i t R ; 10 for each t S in S do 11 j := h(t S.b); 12 if j = 1 then 13 for each t R in Hash - Tabelle mit Schlüssel t S.b do 14 Res := Res (t R t S ); 15 else 16 PS j := PS j t S ; /* Probe - Phase */ 19 for i := 2 to N do 20 Lese PR i ein und erzeuge Hash - Tabelle über t R.a mit t R PR i 21 for each t S in PS i do 22 for each t R in Hash - Tabelle mit Schlüssel t S.b do 23 Res := Res (t R t S ); 4 Index B-Bäume Dieses Kapitel behandelt die physikalische Organisation von Index B-Bäumen im Kontext relationaler Datenbanksysteme. Insbesondere wird das Kompressionsverfahren der Präfix B- Bäume zur Verbesserung des I/O-Verhaltens vorgestellt.

193 187 Ein Index indiziert in Datenbanken eine Relation über Werte bestimmter Spalten(Schlüsselattribute), um die Suche nach diesen zu beschleunigen. Bei einer Anfrage mit einer indizierten Spalte als Suchkriterium bestimmt das Datenbanksystem die Blöcke mit den gewünschten Tupeln auf der Festplatte anhand der Indexstruktur, anstatt alle Blöcke sequentiell durchzusuchen. Als Indexstruktur kommt in der Regel ein B + -Baum zur Anwendung, der eine Erweiterung des B-Baumes [RB72] ist. Abbildung 3: Beispiel einer B-Baum Datenbankindexstruktur für deutsche Kfz-Kennzeichen Ein B + -Baum, wie in Abbildung 3 dargestellt, ist immer ein vollständig balancierter Baum, der sich aus zwei Arten von Knoten aufbaut, die jeweils auf einen Festplattenblock passen. Die inneren Knoten, sogenannte Index-Knoten, enthalten Schlüsseln zum Verzweigen auf Nachfolgeknoten und die Blattknoten zum Verweisen auf die indizierten Tupeln. Anders als bei einfachen B-Bäumen werden hier keine Nutzdaten in Knoten gespeichert, nur die Blätter enthalten Zeiger auf Tupel der indizierten Relation. Eine weitere Eigenschaft vom B + -Baum, die hier nicht weiter betrachtet wird, ist die Verkettung von Blattknoten zu einer linearen Liste, um bei Bereichsanfragen den nächsten Schlüssel aus den benachbarten Blättern nicht wieder von der Wurzel aus zu suchen. Definition 2. (Blockstruktur eines Indexknotens) Ein auf einem Block P gespeicherter Indexknoten fasst maximal B sortierte Schlüssel s 1,..., s m der indizierten Spalte auf und verweist mit den Zeigern p 0,..., p m auf Blöcke P(p i ) der Nachfolgeknoten. P : p 0, s 1, p 1, s 2,..., s i, p i, s i+1,..., s m, p m Sei P ein Block mit einem Indexknoten gemäß Definition 2 und T(P) der zugehörige Unterbaum mit Index- und Blattknoten, den P aufspannt, so teilen die Schlüssel von P die Schlüsselbereiche seiner Unterbäume T(P(p i )) in m + 1 Teilbereiche ein. Für einen Schlüssel x eines Unterbaums T(P(p i )) gilt: x < s i+1 wenn i = 0, s i x < s i+1 wenn i = 1, 2,..., m 1, s i x wenn i = m

194 188 Mit diesem großen Verzweigungsgrad zu maximal B + 1 Unterknoten reduzieren B-Bäume im Vergleich zu Binärbäumen ihre Baumhöhe h und damit die Anzahl der kostspieligen I/O- Zugriffe bei der Suche nach einem Schlüssel von der Wurzel bis zum Blatt. Folglich benötigt das Auffinden eines Tupels über den B + -Baum h Lesezugriffe auf Baumknoten und einen I/O- Zugriff auf das Tupel selbst. Die Baumhöhe h 1 + log B N mit N gespeicherten Schlüsseln hängt vor allem vom konstanten Faktor (Logarithmusbasis), dem Verzweigungsgrad B, ab: Je mehr Schlüssel auf einen Block passen, desto schneller verzweigt die Suche nach einem Schlüssel und erfordert aufgrund geringerer Baumhöhe weniger I/O-Zugriffe. Die für das I/O-Verhalten so wichtige Verzweigungseigenschaft der B + -Bäume ist jedoch stark durch die Blockgröße beschränkt. Auch die Schlüssellänge z.b. bei einem Index über eine Textspalte kann die Belegung eines Knotens verringern. Um mehr Platz für Schlüssel bzw. Verzweigungen pro Knoten zu gewinnen, wird häufig Kompression der Schlüsselattribute angewandt. Kompression erzielt in B + -Bäumen besonders gute Redundanzreduktion, weil alle Schlüssel eines Knotens in einem vom Vaterknoten definierten Bereich liegen. Im nachfolgenden Abschnitt wird das Kompressionsverfahren der Präfix B-Bäume vorgestellt. 4.1 Präfix B-Bäume Der Präfix B-Baum [RB77] ist eine Erweiterung des B-Baumes mit dem Ziel, durch Kompression die Baumhöhe und damit die I/O-Zugriffe auf Knoten weiter zu reduzieren. Dazu werden im Präfix B-Baum Schlüsseln mittels Präfix-Kompression komprimiert, um mehr davon auf einen Festplattenblock zu bekommen. Die Idee ist, für einen Knoten einen Schlüssel-Präfix zu bestimmen, der allen seinen Unerknoten gemeinsam ist, und diesen aus jedem Unterknoten wegen Redundanz zu extrahieren. Das Verfahren verzichtet dabei auf zusätzliche Speicherung der Präfixe, sondern ermittelt sie während der Schlüsselsuche von der Wurzel bis zum Blatt. Die Struktureigenschaften des B-Baumes bleiben somit erhalten. Ein Präfix B-Baum ist auf einem Schlüssel-Alphabet mit lexikographischer Ordnung definiert. Sei P weiterhin ein Block mit einem gespeicherten Index-Knoten und T(P) der zugehörige Unterbaum mit Index- und Blattknoten, den P aufspannt. Die Baumstruktur ordnet jedem Block P eine größte untere λ(p) und eine kleinste obere µ(p) Schlüssel-Schranke zu, zwischen denen alle möglichen Schlüssel x in T(P) liegen: λ(p) x < µ(p). Sei l 0 das kleinste Zeichen des Schlüssel-Alphabets und größer als jedes andere Zeichen, dann gilt für den Block R mit dem Wurzelknoten des Präfix B-Baumes: λ(r) = l 0, µ(r) =. Um λ und µ für andere Knoten zu definieren, sei P ein Index-Knoten nach der Definition 2 mit den unteren und oberen Schranken λ(p) und µ(p). Dann lassen sich die Schranken λ(p(p i )) und µ(p(p i )) für die Unterknoten P(p i ), mit λ(p i ) und µ(p i ) abgekürzt, rekursiv aus den Schlüsseln und Schranken von P definieren: { { si wenn i = 1, 2,..., m, si+1 wenn i = 0, 1,..., m 1, λ(p i ) = µ(p λ(p) wenn i = 0, i ) = µ(p) wenn i = m.

195 189 Weil alle Schlüsseln eines Unterbaums T(p i ) im Bereich zwischen der untersten und obersten Schlüssel-Schranke eingeschlossen sind, müssen sie offensichtlich alle einen gemeinsamen, unter Umständen auch einen leeren, Präfix κ(p i ) haben. Sei k i der längste gemeinsame Präfix von λ(p i ) und µ(p i ), so lautet der Präfix κ(p i ) von P(p i ), der allen Schlüsseln in T(p i ) gemeinsam ist k i l j wenn λ(p i ) = k i l j z und µ(p i ) = k i l j+1, wobei z ein beliebiges Wort und l j+1 κ(p i ) = das unmittelbar nächste Zeichen nach l j in Alphabetordnung ist, sonst. k i Die Definition von κ(p i ) erlaubt in der ersten Alternative k i um ein zusätzliches Zeichen l j zu verlängern, weil aus der Definition der Schranken alle möglichen Schlüsseln x in T(p i ) nur im Bereich k i l j z x < k i l j+1 liegen können und folglich alle den gemeinsamen Präfix k i l j haben. Sowohl λ(p i ) und µ(p i ), und damit auch κ(p i ), sind allein aus der Traversion des Baumes von der Wurzel bis zum Knoten P(p i ) bestimmbar und müssen nicht separat gespeichert werden. Die Kompression des Unterbaums T(p i ) erfolgt dadurch, dass aus jedem darin enthaltenen Schlüssel der Präfix κ(p i ) extrahiert und nur der Rest gespeichert wird. Somit kann ein komprimierter Indexknoten P gemäß Definition 3 mehr Schlüsseln pro Block aufnehmen. Wodurch der Präfix B-Baum, wie in Abbildung 4 dargestellt, eine kleinere Baumhöhe und weniger I/O-Zugriffe als der B + -Baum erzielt. Definition 3. (Blockstruktur eines komprimierten Indexknotens) Ein Indexknoten P eines Präfix B-Baumes speichert im Gegensatz zur Definition 2 die Schlüsseln s 1,..., s m komprimiert ab. Das Komprimat ŝ i eines Schüssels s i ergibt sich aus dem Weglassen des gemeinsamen Präfixes, also s i = κ(p)ŝ i. P : p 0, ŝ 1, p 1, ŝ 2,..., ŝ i, p i, ŝ i+1,..., ŝ m, p m Die Suche nach einem Schlüssel x im Präfix B-Baum verläuft identisch wie im B-Baum ab. Knoten müssen dazu nicht dekomprimiert werden. Die richtige Verzweigung zu Nachfolgeknoten in einem Knoten P wird anhand des vom Präfix κ(p) befreiten Hilfsschlüssels x mit x = κ(p) x ermittelt. Denn wenn x in T(P) liegt, muss es den Präfix κ(p) haben. Dadurch kann zusätzlich die Binärsuche auf den kürzeren Schlüsseln innerhalb eines Knotens profitieren. Analog zu B-Bäumen kann es bei Einfüge- oder Löschoperationen im Präfix B-Baum zu Aufteilung, Verschmelzung oder Verschiebung von Knoten kommen, um Bedingungen für die Gewichtsbalance aufrechtzuerhalten. Dabei müssen unter Umständen Korrekturen an den Präfixen und komprimierten Schlüsseln vorgenommen werden. Bei Aufteilung eines Knotens P in P und P können die neuen Präfixe κ(p ) und κ(p ) länger als κ(p) und die komprimierten Schlüsseln in den neuen Knoten kürzer ausfallen. Anstatt einen vollen Block P aufzuteilen, kann der Überlauf an Schlüsseln in den Bruderknoten T verschoben werden. Infolgedessen sich der Präfix κ(p) verlängern und κ(t) verkürzen bzw. die Schlüssel-Kompression in P verbessern und in T verschlechtern kann. Werden zwei Knoten P und T zu einem neuen Knoten Q verschmolzen, vergrößert sich womöglich der Schlüsselbereich in Q, was zu kürzerem Präfix und schlechterer Kompression in Q führt.

196 190 Zusammengefasst erzielen Präfix B-Bäume eine hohe Redundanzreduktion ohne erheblichen Mehraufwand und ohne größere Änderung an der Struktur einfacher B-Bäume vorzunehmen. Dazu haben experimentelle Untersuchungen in [RB77] gezeigt, dass durch die Kompression erzielte Verdichtung der Schlüssel zur Senkung der Baumhöhe und bis zu 25% weniger I/O- Zugriffen führt. Abbildung 4: Beispiel eines Präfix B-Baumes aus Abbildung 3 (Für jeden Block P ist links λ(p), rechts µ(p) und darüber κ(p) abgebildet) 5 Zusammenfassung In dieser Ausarbeitung wurden Algorithmen und Konzepte für DBMSs vorgestellt, die sich mit Berechnung von Join-Anfragen und dem indexierten Zugriff auf Daten befassen. Ein Problem dieser Algorithmen ist die große Datenmenge, von der nur ein Bruchteil gleichzeitig in den Hauptspeicher oder in den Cache passt. Die Folge sind wiederholte Datentransfers in der Speicherhierarchie von einer Speicherstufe zu einer anderen, die die vorgestellten Algorithmen mittels Lokalitätskriterien versuchen zu reduzieren. Für die Berechnung des Joins wurden zwei Kategorien von Algorithmen betrachtet. Der Nested Loop Join für beliebige Join-Bedingungen wurde in einer I/O- und einer Cacheoptimierter Version vorgestellt. Das Blocken von Daten im Hauptspeicher bzw. im Cache erzielt hier eine höhere zeitliche Lokalität. Im Rahmen der Hash Joins, die nur beim Equi-Join zum Einsatz kommen, wurden der Grace und der Hybrid Algorithmus für das externe Szenario behandelt. Bei beiden wird mit der horizontalen Partitionierung von Daten ein zu Eingabedaten lineares I/O-Verhalten erreicht. Um den Zugriff auf Tupel auf der Festplatte über einen Index zu beschleunigen, wurde der Einsatz von Index B-Bäumen in Datenbanken vorgestellt. Aufgrund des hohen Verzweigungsgrades von B-Bäumen erlauben sie auch bei großen Datenmengen ein Tupel über ein Schlüssel mit sehr wenigen I/O-Zugriffen aufzufinden. Einen noch höheren Verzweigungsgrad, somit besseres I/O-Verhalten, erreichen Präfix B-Bäume durch Kompression der Schlüssel. Die Kompression bewirkt hier eine höhere räumliche Lokalität von zusammengehöriger Schlüssel.

1 topologisches Sortieren

1 topologisches Sortieren Wolfgang Hönig / Andreas Ecke WS 09/0 topologisches Sortieren. Überblick. Solange noch Knoten vorhanden: a) Suche Knoten v, zu dem keine Kante führt (Falls nicht vorhanden keine topologische Sortierung

Mehr

50. Mathematik-Olympiade 2. Stufe (Regionalrunde) Klasse 11 13. 501322 Lösung 10 Punkte

50. Mathematik-Olympiade 2. Stufe (Regionalrunde) Klasse 11 13. 501322 Lösung 10 Punkte 50. Mathematik-Olympiade. Stufe (Regionalrunde) Klasse 3 Lösungen c 00 Aufgabenausschuss des Mathematik-Olympiaden e.v. www.mathematik-olympiaden.de. Alle Rechte vorbehalten. 503 Lösung 0 Punkte Es seien

Mehr

Primzahlen und RSA-Verschlüsselung

Primzahlen und RSA-Verschlüsselung Primzahlen und RSA-Verschlüsselung Michael Fütterer und Jonathan Zachhuber 1 Einiges zu Primzahlen Ein paar Definitionen: Wir bezeichnen mit Z die Menge der positiven und negativen ganzen Zahlen, also

Mehr

Kapitel 8: Physischer Datenbankentwurf

Kapitel 8: Physischer Datenbankentwurf 8. Physischer Datenbankentwurf Seite 1 Kapitel 8: Physischer Datenbankentwurf Speicherung und Verwaltung der Relationen einer relationalen Datenbank so, dass eine möglichst große Effizienz der einzelnen

Mehr

Konzepte der Informatik

Konzepte der Informatik Konzepte der Informatik Vorkurs Informatik zum WS 2011/2012 26.09. - 30.09.2011 17.10. - 21.10.2011 Dr. Werner Struckmann / Christoph Peltz Stark angelehnt an Kapitel 1 aus "Abenteuer Informatik" von Jens

Mehr

Kompetitive Analysen von Online-Algorithmen

Kompetitive Analysen von Online-Algorithmen Kompetitive Analysen von Online-Algorithmen jonas echterhoff 16. Juli 004 1 Einführung 1.1 Terminologie Online-Algorithmen sind Algorithmen, die Probleme lösen sollen, bei denen Entscheidungen getroffen

Mehr

1 Mathematische Grundlagen

1 Mathematische Grundlagen Mathematische Grundlagen - 1-1 Mathematische Grundlagen Der Begriff der Menge ist einer der grundlegenden Begriffe in der Mathematik. Mengen dienen dazu, Dinge oder Objekte zu einer Einheit zusammenzufassen.

Mehr

Informationsblatt Induktionsbeweis

Informationsblatt Induktionsbeweis Sommer 015 Informationsblatt Induktionsbeweis 31. März 015 Motivation Die vollständige Induktion ist ein wichtiges Beweisverfahren in der Informatik. Sie wird häufig dazu gebraucht, um mathematische Formeln

Mehr

Professionelle Seminare im Bereich MS-Office

Professionelle Seminare im Bereich MS-Office Der Name BEREICH.VERSCHIEBEN() ist etwas unglücklich gewählt. Man kann mit der Funktion Bereiche zwar verschieben, man kann Bereiche aber auch verkleinern oder vergrößern. Besser wäre es, die Funktion

Mehr

Ist Excel das richtige Tool für FMEA? Steve Murphy, Marc Schaeffers

Ist Excel das richtige Tool für FMEA? Steve Murphy, Marc Schaeffers Ist Excel das richtige Tool für FMEA? Steve Murphy, Marc Schaeffers Ist Excel das richtige Tool für FMEA? Einleitung Wenn in einem Unternehmen FMEA eingeführt wird, fangen die meisten sofort damit an,

Mehr

Grundlagen verteilter Systeme

Grundlagen verteilter Systeme Universität Augsburg Insitut für Informatik Prof. Dr. Bernhard Bauer Wolf Fischer Christian Saad Wintersemester 08/09 Übungsblatt 3 12.11.08 Grundlagen verteilter Systeme Lösungsvorschlag Aufgabe 1: a)

Mehr

Datenstrukturen & Algorithmen

Datenstrukturen & Algorithmen Datenstrukturen & Algorithmen Matthias Zwicker Universität Bern Frühling 2010 Übersicht Binäre Suchbäume Einführung und Begriffe Binäre Suchbäume 2 Binäre Suchbäume Datenstruktur für dynamische Mengen

Mehr

Sortierverfahren für Felder (Listen)

Sortierverfahren für Felder (Listen) Sortierverfahren für Felder (Listen) Generell geht es um die Sortierung von Daten nach einem bestimmten Sortierschlüssel. Es ist auch möglich, daß verschiedene Daten denselben Sortierschlüssel haben. Es

Mehr

B-Bäume I. Algorithmen und Datenstrukturen 220 DATABASE SYSTEMS GROUP

B-Bäume I. Algorithmen und Datenstrukturen 220 DATABASE SYSTEMS GROUP B-Bäume I Annahme: Sei die Anzahl der Objekte und damit der Datensätze. Das Datenvolumen ist zu groß, um im Hauptspeicher gehalten zu werden, z.b. 10. Datensätze auf externen Speicher auslagern, z.b. Festplatte

Mehr

Nutzung von GiS BasePac 8 im Netzwerk

Nutzung von GiS BasePac 8 im Netzwerk Allgemeines Grundsätzlich kann das GiS BasePac Programm in allen Netzwerken eingesetzt werden, die Verbindungen als Laufwerk zu lassen (alle WINDOWS Versionen). Die GiS Software unterstützt nur den Zugriff

Mehr

Lineargleichungssysteme: Additions-/ Subtraktionsverfahren

Lineargleichungssysteme: Additions-/ Subtraktionsverfahren Lineargleichungssysteme: Additions-/ Subtraktionsverfahren W. Kippels 22. Februar 2014 Inhaltsverzeichnis 1 Einleitung 2 2 Lineargleichungssysteme zweiten Grades 2 3 Lineargleichungssysteme höheren als

Mehr

Güte von Tests. die Wahrscheinlichkeit für den Fehler 2. Art bei der Testentscheidung, nämlich. falsch ist. Darauf haben wir bereits im Kapitel über

Güte von Tests. die Wahrscheinlichkeit für den Fehler 2. Art bei der Testentscheidung, nämlich. falsch ist. Darauf haben wir bereits im Kapitel über Güte von s Grundlegendes zum Konzept der Güte Ableitung der Gütefunktion des Gauss im Einstichprobenproblem Grafische Darstellung der Gütefunktionen des Gauss im Einstichprobenproblem Ableitung der Gütefunktion

Mehr

4 Aufzählungen und Listen erstellen

4 Aufzählungen und Listen erstellen 4 4 Aufzählungen und Listen erstellen Beim Strukturieren von Dokumenten und Inhalten stellen Listen und Aufzählungen wichtige Werkzeuge dar. Mit ihnen lässt sich so ziemlich alles sortieren, was auf einer

Mehr

Outlook. sysplus.ch outlook - mail-grundlagen Seite 1/8. Mail-Grundlagen. Posteingang

Outlook. sysplus.ch outlook - mail-grundlagen Seite 1/8. Mail-Grundlagen. Posteingang sysplus.ch outlook - mail-grundlagen Seite 1/8 Outlook Mail-Grundlagen Posteingang Es gibt verschiedene Möglichkeiten, um zum Posteingang zu gelangen. Man kann links im Outlook-Fenster auf die Schaltfläche

Mehr

OPERATIONEN AUF EINER DATENBANK

OPERATIONEN AUF EINER DATENBANK Einführung 1 OPERATIONEN AUF EINER DATENBANK Ein Benutzer stellt eine Anfrage: Die Benutzer einer Datenbank können meist sowohl interaktiv als auch über Anwendungen Anfragen an eine Datenbank stellen:

Mehr

Kapiteltests zum Leitprogramm Binäre Suchbäume

Kapiteltests zum Leitprogramm Binäre Suchbäume Kapiteltests zum Leitprogramm Binäre Suchbäume Björn Steffen Timur Erdag überarbeitet von Christina Class Binäre Suchbäume Kapiteltests für das ETH-Leitprogramm Adressaten und Institutionen Das Leitprogramm

Mehr

4. Jeder Knoten hat höchstens zwei Kinder, ein linkes und ein rechtes.

4. Jeder Knoten hat höchstens zwei Kinder, ein linkes und ein rechtes. Binäre Bäume Definition: Ein binärer Baum T besteht aus einer Menge von Knoten, die durch eine Vater-Kind-Beziehung wie folgt strukturiert ist: 1. Es gibt genau einen hervorgehobenen Knoten r T, die Wurzel

Mehr

Insiderwissen 2013. Hintergrund

Insiderwissen 2013. Hintergrund Insiderwissen 213 XING EVENTS mit der Eventmanagement-Software für Online Eventregistrierung &Ticketing amiando, hat es sich erneut zur Aufgabe gemacht zu analysieren, wie Eventveranstalter ihre Veranstaltungen

Mehr

Zwischenablage (Bilder, Texte,...)

Zwischenablage (Bilder, Texte,...) Zwischenablage was ist das? Informationen über. die Bedeutung der Windows-Zwischenablage Kopieren und Einfügen mit der Zwischenablage Vermeiden von Fehlern beim Arbeiten mit der Zwischenablage Bei diesen

Mehr

Folge 19 - Bäume. 19.1 Binärbäume - Allgemeines. Grundlagen: Ulrich Helmich: Informatik 2 mit BlueJ - Ein Kurs für die Stufe 12

Folge 19 - Bäume. 19.1 Binärbäume - Allgemeines. Grundlagen: Ulrich Helmich: Informatik 2 mit BlueJ - Ein Kurs für die Stufe 12 Grundlagen: Folge 19 - Bäume 19.1 Binärbäume - Allgemeines Unter Bäumen versteht man in der Informatik Datenstrukturen, bei denen jedes Element mindestens zwei Nachfolger hat. Bereits in der Folge 17 haben

Mehr

Windows Vista Security

Windows Vista Security Marcel Zehner Windows Vista Security ISBN-10: 3-446-41356-1 ISBN-13: 978-3-446-41356-6 Leseprobe Weitere Informationen oder Bestellungen unter http://www.hanser.de/978-3-446-41356-6 sowie im Buchhandel

Mehr

Dokumentation IBIS Monitor

Dokumentation IBIS Monitor Dokumentation IBIS Monitor Seite 1 von 16 11.01.06 Inhaltsverzeichnis 1. Allgemein 2. Installation und Programm starten 3. Programmkonfiguration 4. Aufzeichnung 4.1 Aufzeichnung mitschneiden 4.1.1 Inhalt

Mehr

1 Einleitung. 1.1 Motivation und Zielsetzung der Untersuchung

1 Einleitung. 1.1 Motivation und Zielsetzung der Untersuchung 1 Einleitung 1.1 Motivation und Zielsetzung der Untersuchung Obgleich Tourenplanungsprobleme zu den am häufigsten untersuchten Problemstellungen des Operations Research zählen, konzentriert sich der Großteil

Mehr

Einführung in die technische Informatik

Einführung in die technische Informatik Einführung in die technische Informatik Christopher Kruegel chris@auto.tuwien.ac.at http://www.auto.tuwien.ac.at/~chris Betriebssysteme Aufgaben Management von Ressourcen Präsentation einer einheitlichen

Mehr

Würfelt man dabei je genau 10 - mal eine 1, 2, 3, 4, 5 und 6, so beträgt die Anzahl. der verschiedenen Reihenfolgen, in denen man dies tun kann, 60!.

Würfelt man dabei je genau 10 - mal eine 1, 2, 3, 4, 5 und 6, so beträgt die Anzahl. der verschiedenen Reihenfolgen, in denen man dies tun kann, 60!. 040304 Übung 9a Analysis, Abschnitt 4, Folie 8 Die Wahrscheinlichkeit, dass bei n - maliger Durchführung eines Zufallexperiments ein Ereignis A ( mit Wahrscheinlichkeit p p ( A ) ) für eine beliebige Anzahl

Mehr

Kapitel 6 Anfragebearbeitung

Kapitel 6 Anfragebearbeitung LUDWIG- MAXIMILIANS- UNIVERSITY MUNICH DEPARTMENT INSTITUTE FOR INFORMATICS DATABASE Skript zur Vorlesung: Datenbanksysteme II Sommersemester 2014 Kapitel 6 Anfragebearbeitung Vorlesung: PD Dr. Peer Kröger

Mehr

Binäre Bäume. 1. Allgemeines. 2. Funktionsweise. 2.1 Eintragen

Binäre Bäume. 1. Allgemeines. 2. Funktionsweise. 2.1 Eintragen Binäre Bäume 1. Allgemeines Binäre Bäume werden grundsätzlich verwendet, um Zahlen der Größe nach, oder Wörter dem Alphabet nach zu sortieren. Dem einfacheren Verständnis zu Liebe werde ich mich hier besonders

Mehr

AGROPLUS Buchhaltung. Daten-Server und Sicherheitskopie. Version vom 21.10.2013b

AGROPLUS Buchhaltung. Daten-Server und Sicherheitskopie. Version vom 21.10.2013b AGROPLUS Buchhaltung Daten-Server und Sicherheitskopie Version vom 21.10.2013b 3a) Der Daten-Server Modus und der Tresor Der Daten-Server ist eine Betriebsart welche dem Nutzer eine grosse Flexibilität

Mehr

Lizenzierung von SharePoint Server 2013

Lizenzierung von SharePoint Server 2013 Lizenzierung von SharePoint Server 2013 Das Lizenzmodell von SharePoint Server 2013 besteht aus zwei Komponenten: Serverlizenzen zur Lizenzierung der Serversoftware und CALs zur Lizenzierung der Zugriffe

Mehr

Technical Note Nr. 101

Technical Note Nr. 101 Seite 1 von 6 DMS und Schleifringübertrager-Schaltungstechnik Über Schleifringübertrager können DMS-Signale in exzellenter Qualität übertragen werden. Hierbei haben sowohl die physikalischen Eigenschaften

Mehr

OECD Programme for International Student Assessment PISA 2000. Lösungen der Beispielaufgaben aus dem Mathematiktest. Deutschland

OECD Programme for International Student Assessment PISA 2000. Lösungen der Beispielaufgaben aus dem Mathematiktest. Deutschland OECD Programme for International Student Assessment Deutschland PISA 2000 Lösungen der Beispielaufgaben aus dem Mathematiktest Beispielaufgaben PISA-Hauptstudie 2000 Seite 3 UNIT ÄPFEL Beispielaufgaben

Mehr

Über Arrays und verkettete Listen Listen in Delphi

Über Arrays und verkettete Listen Listen in Delphi Über Arrays und verkettete Listen Listen in Delphi Michael Puff mail@michael-puff.de 2010-03-26 Inhaltsverzeichnis Inhaltsverzeichnis 1 Einführung 3 2 Arrays 4 3 Einfach verkettete Listen 7 4 Doppelt verkettete

Mehr

Bedienung des Web-Portales der Sportbergbetriebe

Bedienung des Web-Portales der Sportbergbetriebe Bedienung des Web-Portales der Sportbergbetriebe Allgemein Über dieses Web-Portal, können sich Tourismusbetriebe via Internet präsentieren, wobei jeder Betrieb seine Daten zu 100% selbst warten kann. Anfragen

Mehr

1. Einfach verkettete Liste unsortiert 2. Einfach verkettete Liste sortiert 3. Doppelt verkettete Liste sortiert

1. Einfach verkettete Liste unsortiert 2. Einfach verkettete Liste sortiert 3. Doppelt verkettete Liste sortiert Inhalt Einführung 1. Arrays 1. Array unsortiert 2. Array sortiert 3. Heap 2. Listen 1. Einfach verkettete Liste unsortiert 2. Einfach verkettete Liste sortiert 3. Doppelt verkettete Liste sortiert 3. Bäume

Mehr

Fachbericht zum Thema: Anforderungen an ein Datenbanksystem

Fachbericht zum Thema: Anforderungen an ein Datenbanksystem Fachbericht zum Thema: Anforderungen an ein Datenbanksystem von André Franken 1 Inhaltsverzeichnis 1 Inhaltsverzeichnis 1 2 Einführung 2 2.1 Gründe für den Einsatz von DB-Systemen 2 2.2 Definition: Datenbank

Mehr

PTV VISWALK TIPPS UND TRICKS PTV VISWALK TIPPS UND TRICKS: VERWENDUNG DICHTEBASIERTER TEILROUTEN

PTV VISWALK TIPPS UND TRICKS PTV VISWALK TIPPS UND TRICKS: VERWENDUNG DICHTEBASIERTER TEILROUTEN PTV VISWALK TIPPS UND TRICKS PTV VISWALK TIPPS UND TRICKS: VERWENDUNG DICHTEBASIERTER TEILROUTEN Karlsruhe, April 2015 Verwendung dichte-basierter Teilrouten Stellen Sie sich vor, in einem belebten Gebäude,

Mehr

MS Excel 2010 Kompakt

MS Excel 2010 Kompakt MS Excel 00 Kompakt FILTERN Aus einem großen Datenbestand sollen nur jene Datensätze (Zeilen) angezeigt werden, die einem bestimmten Eintrag eines Feldes (Spalte) entsprechen. Excel unterstützt Filterungen

Mehr

Datenbanken Kapitel 2

Datenbanken Kapitel 2 Datenbanken Kapitel 2 1 Eine existierende Datenbank öffnen Eine Datenbank, die mit Microsoft Access erschaffen wurde, kann mit dem gleichen Programm auch wieder geladen werden: Die einfachste Methode ist,

Mehr

Anleitung zum online Datenbezug. Inhalt:

Anleitung zum online Datenbezug. Inhalt: Geodatendrehscheibe Graubünden Mail info@geogr.ch Postfach 354, 7002 Chur www.geogr.ch Anleitung zum online Datenbezug Inhalt: 1. Anmeldung... 2 2. Kurze Info zum Inhalt der Startseite des Shops (Home)...

Mehr

mobilepoi 0.91 Demo Version Anleitung Das Software Studio Christian Efinger Erstellt am 21. Oktober 2005

mobilepoi 0.91 Demo Version Anleitung Das Software Studio Christian Efinger Erstellt am 21. Oktober 2005 Das Software Studio Christian Efinger mobilepoi 0.91 Demo Version Anleitung Erstellt am 21. Oktober 2005 Kontakt: Das Software Studio Christian Efinger ce@efinger-online.de Inhalt 1. Einführung... 3 2.

Mehr

S7-Hantierungsbausteine für R355, R6000 und R2700

S7-Hantierungsbausteine für R355, R6000 und R2700 S7-Hantierungsbausteine für R355, R6000 und R2700 1. FB90, Zyklus_R/W Dieser Baustein dient zur zentralen Kommunikation zwischen Anwenderprogramm und dem Modul R355 sowie den Geräten R6000 und R2700 über

Mehr

Eigene Dokumente, Fotos, Bilder etc. sichern

Eigene Dokumente, Fotos, Bilder etc. sichern Eigene Dokumente, Fotos, Bilder etc. sichern Solange alles am PC rund läuft, macht man sich keine Gedanken darüber, dass bei einem Computer auch mal ein technischer Defekt auftreten könnte. Aber Grundsätzliches

Mehr

Grundlagen der Theoretischen Informatik, SoSe 2008

Grundlagen der Theoretischen Informatik, SoSe 2008 1. Aufgabenblatt zur Vorlesung Grundlagen der Theoretischen Informatik, SoSe 2008 (Dr. Frank Hoffmann) Lösung von Manuel Jain und Benjamin Bortfeldt Aufgabe 2 Zustandsdiagramme (6 Punkte, wird korrigiert)

Mehr

Einführung in. Logische Schaltungen

Einführung in. Logische Schaltungen Einführung in Logische Schaltungen 1/7 Inhaltsverzeichnis 1. Einführung 1. Was sind logische Schaltungen 2. Grundlegende Elemente 3. Weitere Elemente 4. Beispiel einer logischen Schaltung 2. Notation von

Mehr

Handbuch Fischertechnik-Einzelteiltabelle V3.7.3

Handbuch Fischertechnik-Einzelteiltabelle V3.7.3 Handbuch Fischertechnik-Einzelteiltabelle V3.7.3 von Markus Mack Stand: Samstag, 17. April 2004 Inhaltsverzeichnis 1. Systemvorraussetzungen...3 2. Installation und Start...3 3. Anpassen der Tabelle...3

Mehr

Algorithmen und Datenstrukturen Balancierte Suchbäume

Algorithmen und Datenstrukturen Balancierte Suchbäume Algorithmen und Datenstrukturen Balancierte Suchbäume Matthias Teschner Graphische Datenverarbeitung Institut für Informatik Universität Freiburg SS 12 Überblick Einführung Einfügen und Löschen Einfügen

Mehr

Nutzer-Synchronisation mittels WebWeaver Desktop. Handreichung

Nutzer-Synchronisation mittels WebWeaver Desktop. Handreichung Nutzer-Synchronisation mittels WebWeaver Desktop Handreichung Allgemeine Hinweise Um die Synchronisation der Nutzerdaten durchzuführen, starten Sie WebWeaver Desktop bitte ausschließlich mit dem für Ihre

Mehr

Dokumentation zur Versendung der Statistik Daten

Dokumentation zur Versendung der Statistik Daten Dokumentation zur Versendung der Statistik Daten Achtung: gem. 57a KFG 1967 (i.d.f. der 28. Novelle) ist es seit dem 01. August 2007 verpflichtend, die Statistikdaten zur statistischen Auswertung Quartalsmäßig

Mehr

Woraus besteht ein Bild? 28.02.2008 (c) Winfried Heinkele 2006 2

Woraus besteht ein Bild? 28.02.2008 (c) Winfried Heinkele 2006 2 Woraus besteht ein Bild? 28.02.2008 (c) Winfried Heinkele 2006 2 Was ist ein Pixel? Die durch das Objektiv einer Kamera auf einen Film oder einen elektronischen Bildsensor projizierte Wirklichkeit ist

Mehr

WinWerk. Prozess 6a Rabatt gemäss Vorjahresverbrauch. KMU Ratgeber AG. Inhaltsverzeichnis. Im Ifang 16 8307 Effretikon

WinWerk. Prozess 6a Rabatt gemäss Vorjahresverbrauch. KMU Ratgeber AG. Inhaltsverzeichnis. Im Ifang 16 8307 Effretikon WinWerk Prozess 6a Rabatt gemäss Vorjahresverbrauch 8307 Effretikon Telefon: 052-740 11 11 Telefax: 052-740 11 71 E-Mail info@kmuratgeber.ch Internet: www.winwerk.ch Inhaltsverzeichnis 1 Ablauf der Rabattverarbeitung...

Mehr

Unterscheidung: Workflowsystem vs. Informationssystem

Unterscheidung: Workflowsystem vs. Informationssystem 1. Vorwort 1.1. Gemeinsamkeiten Unterscheidung: Workflowsystem vs. Die Überschneidungsfläche zwischen Workflowsystem und ist die Domäne, also dass es darum geht, Varianten eines Dokuments schrittweise

Mehr

1. Motivation / Grundlagen 2. Sortierverfahren 3. Elementare Datenstrukturen / Anwendungen 4. Bäume / Graphen 5. Hashing 6. Algorithmische Geometrie

1. Motivation / Grundlagen 2. Sortierverfahren 3. Elementare Datenstrukturen / Anwendungen 4. Bäume / Graphen 5. Hashing 6. Algorithmische Geometrie Gliederung 1. Motivation / Grundlagen 2. Sortierverfahren 3. Elementare Datenstrukturen / Anwendungen 4. äume / Graphen 5. Hashing 6. Algorithmische Geometrie 4/5, olie 1 2014 Prof. Steffen Lange - HDa/bI

Mehr

Lizenzierung von SharePoint Server 2013

Lizenzierung von SharePoint Server 2013 Lizenzierung von SharePoint Server 2013 Das Lizenzmodell von SharePoint Server 2013 besteht aus zwei Komponenten: Serverlizenzen zur Lizenzierung der Serversoftware und CALs zur Lizenzierung der Zugriffe

Mehr

Handbuch ECDL 2003 Basic Modul 5: Datenbank Grundlagen von relationalen Datenbanken

Handbuch ECDL 2003 Basic Modul 5: Datenbank Grundlagen von relationalen Datenbanken Handbuch ECDL 2003 Basic Modul 5: Datenbank Grundlagen von relationalen Datenbanken Dateiname: ecdl5_01_00_documentation_standard.doc Speicherdatum: 14.02.2005 ECDL 2003 Basic Modul 5 Datenbank - Grundlagen

Mehr

Automatisches Parallelisieren

Automatisches Parallelisieren Automatisches Parallelisieren Vorlesung im Wintersemester 2010/11 Eberhard Zehendner FSU Jena Thema: Datenabhängigkeitsanalyse Eberhard Zehendner (FSU Jena) Automatisches Parallelisieren Datenabhängigkeitsanalyse

Mehr

Microsoft PowerPoint 2013 Folien gemeinsam nutzen

Microsoft PowerPoint 2013 Folien gemeinsam nutzen Hochschulrechenzentrum Justus-Liebig-Universität Gießen Microsoft PowerPoint 2013 Folien gemeinsam nutzen Folien gemeinsam nutzen in PowerPoint 2013 Seite 1 von 4 Inhaltsverzeichnis Einleitung... 2 Einzelne

Mehr

Round-Robin Scheduling (RR)

Round-Robin Scheduling (RR) RR - Scheduling Reigen-Modell: einfachster, ältester, fairster, am weitesten verbreiteter Algorithmus Entworfen für interaktive Systeme (preemptives Scheduling) Idee: Den Prozessen in der Bereitschaftsschlange

Mehr

Kurzanleitung fu r Clubbeauftragte zur Pflege der Mitgliederdaten im Mitgliederbereich

Kurzanleitung fu r Clubbeauftragte zur Pflege der Mitgliederdaten im Mitgliederbereich Kurzanleitung fu r Clubbeauftragte zur Pflege der Mitgliederdaten im Mitgliederbereich Mitgliederbereich (Version 1.0) Bitte loggen Sie sich in den Mitgliederbereich mit den Ihnen bekannten Zugangsdaten

Mehr

1. Man schreibe die folgenden Aussagen jeweils in einen normalen Satz um. Zum Beispiel kann man die Aussage:

1. Man schreibe die folgenden Aussagen jeweils in einen normalen Satz um. Zum Beispiel kann man die Aussage: Zählen und Zahlbereiche Übungsblatt 1 1. Man schreibe die folgenden Aussagen jeweils in einen normalen Satz um. Zum Beispiel kann man die Aussage: Für alle m, n N gilt m + n = n + m. in den Satz umschreiben:

Mehr

Berechnung der Erhöhung der Durchschnittsprämien

Berechnung der Erhöhung der Durchschnittsprämien Wolfram Fischer Berechnung der Erhöhung der Durchschnittsprämien Oktober 2004 1 Zusammenfassung Zur Berechnung der Durchschnittsprämien wird das gesamte gemeldete Prämienvolumen Zusammenfassung durch die

Mehr

Algorithmen II Vorlesung am 15.11.2012

Algorithmen II Vorlesung am 15.11.2012 Algorithmen II Vorlesung am 15.11.2012 Kreisbasen, Matroide & Algorithmen INSTITUT FÜR THEORETISCHE INFORMATIK PROF. DR. DOROTHEA WAGNER KIT Universität des Landes Baden-Württemberg und Algorithmen nationales

Mehr

Binäre Bäume Darstellung und Traversierung

Binäre Bäume Darstellung und Traversierung Binäre Bäume Darstellung und Traversierung Name Frank Bollwig Matrikel-Nr. 2770085 E-Mail fb641378@inf.tu-dresden.de Datum 15. November 2001 0. Vorbemerkungen... 3 1. Terminologie binärer Bäume... 4 2.

Mehr

Anmerkungen zur Übergangsprüfung

Anmerkungen zur Übergangsprüfung DM11 Slide 1 Anmerkungen zur Übergangsprüfung Aufgabeneingrenzung Aufgaben des folgenden Typs werden wegen ihres Schwierigkeitsgrads oder wegen eines ungeeigneten fachlichen Schwerpunkts in der Übergangsprüfung

Mehr

15 Optimales Kodieren

15 Optimales Kodieren 15 Optimales Kodieren Es soll ein optimaler Kodierer C(T ) entworfen werden, welcher eine Information (z.b. Text T ) mit möglichst geringer Bitanzahl eindeutig überträgt. Die Anforderungen an den optimalen

Mehr

IRF2000 Application Note Lösung von IP-Adresskonflikten bei zwei identischen Netzwerken

IRF2000 Application Note Lösung von IP-Adresskonflikten bei zwei identischen Netzwerken Version 2.0 1 Original-Application Note ads-tec GmbH IRF2000 Application Note Lösung von IP-Adresskonflikten bei zwei identischen Netzwerken Stand: 27.10.2014 ads-tec GmbH 2014 IRF2000 2 Inhaltsverzeichnis

Mehr

Kapitel 4 Die Datenbank Kuchenbestellung Seite 1

Kapitel 4 Die Datenbank Kuchenbestellung Seite 1 Kapitel 4 Die Datenbank Kuchenbestellung Seite 1 4 Die Datenbank Kuchenbestellung In diesem Kapitel werde ich die Theorie aus Kapitel 2 Die Datenbank Buchausleihe an Hand einer weiteren Datenbank Kuchenbestellung

Mehr

Monitore. Klicken bearbeiten

Monitore. Klicken bearbeiten Sascha Kretzschmann Institut für Informatik Monitore Formatvorlage und deren Umsetzung des Untertitelmasters durch Klicken bearbeiten Inhalt 1. Monitore und Concurrent Pascal 1.1 Warum Monitore? 1.2 Monitordefinition

Mehr

iphone- und ipad-praxis: Kalender optimal synchronisieren

iphone- und ipad-praxis: Kalender optimal synchronisieren 42 iphone- und ipad-praxis: Kalender optimal synchronisieren Die Synchronisierung von ios mit anderen Kalendern ist eine elementare Funktion. Die Standard-App bildet eine gute Basis, für eine optimale

Mehr

Zeichen bei Zahlen entschlüsseln

Zeichen bei Zahlen entschlüsseln Zeichen bei Zahlen entschlüsseln In diesem Kapitel... Verwendung des Zahlenstrahls Absolut richtige Bestimmung von absoluten Werten Operationen bei Zahlen mit Vorzeichen: Addieren, Subtrahieren, Multiplizieren

Mehr

Synchronisations- Assistent

Synchronisations- Assistent TimePunch Synchronisations- Assistent Benutzerhandbuch Gerhard Stephan Softwareentwicklung -und Vertrieb 25.08.2011 Dokumenten Information: Dokumenten-Name Benutzerhandbuch, Synchronisations-Assistent

Mehr

Systeme 1. Kapitel 6. Nebenläufigkeit und wechselseitiger Ausschluss

Systeme 1. Kapitel 6. Nebenläufigkeit und wechselseitiger Ausschluss Systeme 1 Kapitel 6 Nebenläufigkeit und wechselseitiger Ausschluss Threads Die Adressräume verschiedener Prozesse sind getrennt und geschützt gegen den Zugriff anderer Prozesse. Threads sind leichtgewichtige

Mehr

Korrigenda Handbuch der Bewertung

Korrigenda Handbuch der Bewertung Korrigenda Handbuch der Bewertung Kapitel 3 Abschnitt 3.5 Seite(n) 104-109 Titel Der Terminvertrag: Ein Beispiel für den Einsatz von Future Values Änderungen In den Beispielen 21 und 22 ist der Halbjahressatz

Mehr

Kontakte Dorfstrasse 143 CH - 8802 Kilchberg Telefon 01 / 716 10 00 Telefax 01 / 716 10 05 info@hp-engineering.com www.hp-engineering.

Kontakte Dorfstrasse 143 CH - 8802 Kilchberg Telefon 01 / 716 10 00 Telefax 01 / 716 10 05 info@hp-engineering.com www.hp-engineering. Kontakte Kontakte Seite 1 Kontakte Seite 2 Inhaltsverzeichnis 1. ALLGEMEINE INFORMATIONEN ZU DEN KONTAKTEN 4 2. WICHTIGE INFORMATIONEN ZUR BEDIENUNG VON CUMULUS 4 3. STAMMDATEN FÜR DIE KONTAKTE 4 4. ARBEITEN

Mehr

Modellbildungssysteme: Pädagogische und didaktische Ziele

Modellbildungssysteme: Pädagogische und didaktische Ziele Modellbildungssysteme: Pädagogische und didaktische Ziele Was hat Modellbildung mit der Schule zu tun? Der Bildungsplan 1994 formuliert: "Die schnelle Zunahme des Wissens, die hohe Differenzierung und

Mehr

SUDOKU - Strategien zur Lösung

SUDOKU - Strategien zur Lösung SUDOKU Strategien v. /00 SUDOKU - Strategien zur Lösung. Naked Single (Eindeutiger Wert)? "Es gibt nur einen einzigen Wert, der hier stehen kann". Sind alle anderen Werte bis auf einen für eine Zelle unmöglich,

Mehr

7 Rechnen mit Polynomen

7 Rechnen mit Polynomen 7 Rechnen mit Polynomen Zu Polynomfunktionen Satz. Zwei Polynomfunktionen und f : R R, x a n x n + a n 1 x n 1 + a 1 x + a 0 g : R R, x b n x n + b n 1 x n 1 + b 1 x + b 0 sind genau dann gleich, wenn

Mehr

Webalizer HOWTO. Stand: 18.06.2012

Webalizer HOWTO. Stand: 18.06.2012 Webalizer HOWTO Stand: 18.06.2012 Copyright 2003 by manitu. Alle Rechte vorbehalten. Alle verwendeten Bezeichnungen dienen lediglich der Kennzeichnung und können z.t. eingetragene Warenzeichen sein, ohne

Mehr

Lieferschein Dorfstrasse 143 CH - 8802 Kilchberg Telefon 01 / 716 10 00 Telefax 01 / 716 10 05 info@hp-engineering.com www.hp-engineering.

Lieferschein Dorfstrasse 143 CH - 8802 Kilchberg Telefon 01 / 716 10 00 Telefax 01 / 716 10 05 info@hp-engineering.com www.hp-engineering. Lieferschein Lieferscheine Seite 1 Lieferscheine Seite 2 Inhaltsverzeichnis 1. STARTEN DER LIEFERSCHEINE 4 2. ARBEITEN MIT DEN LIEFERSCHEINEN 4 2.1 ERFASSEN EINES NEUEN LIEFERSCHEINS 5 2.1.1 TEXTFELD FÜR

Mehr

Erstellung von Reports mit Anwender-Dokumentation und System-Dokumentation in der ArtemiS SUITE (ab Version 5.0)

Erstellung von Reports mit Anwender-Dokumentation und System-Dokumentation in der ArtemiS SUITE (ab Version 5.0) Erstellung von und System-Dokumentation in der ArtemiS SUITE (ab Version 5.0) In der ArtemiS SUITE steht eine neue, sehr flexible Reporting-Funktion zur Verfügung, die mit der Version 5.0 noch einmal verbessert

Mehr

Guide DynDNS und Portforwarding

Guide DynDNS und Portforwarding Guide DynDNS und Portforwarding Allgemein Um Geräte im lokalen Netzwerk von überall aus über das Internet erreichen zu können, kommt man um die Themen Dynamik DNS (kurz DynDNS) und Portweiterleitung(auch

Mehr

Jederzeit Ordnung halten

Jederzeit Ordnung halten Kapitel Jederzeit Ordnung halten 6 auf Ihrem Mac In diesem Buch war bereits einige Male vom Finder die Rede. Dieses Kapitel wird sich nun ausführlich diesem so wichtigen Programm widmen. Sie werden das

Mehr

Algorithmische Mathematik

Algorithmische Mathematik Algorithmische Mathematik Wintersemester 2013 Prof. Dr. Marc Alexander Schweitzer und Dr. Einar Smith Patrick Diehl und Daniel Wissel Übungsblatt 6. Abgabe am 02.12.2013. Aufgabe 1. (Netzwerke und Definitionen)

Mehr

Zulassung nach MID (Measurement Instruments Directive)

Zulassung nach MID (Measurement Instruments Directive) Anwender - I n f o MID-Zulassung H 00.01 / 12.08 Zulassung nach MID (Measurement Instruments Directive) Inhaltsverzeichnis 1. Hinweis 2. Gesetzesgrundlage 3. Inhalte 4. Zählerkennzeichnung/Zulassungszeichen

Mehr

Ein mobiler Electronic Program Guide

Ein mobiler Electronic Program Guide Whitepaper Telekommunikation Ein mobiler Electronic Program Guide Ein iphone Prototyp auf Basis von Web-Technologien 2011 SYRACOM AG 1 Einleitung Apps Anwendungen für mobile Geräte sind derzeit in aller

Mehr

Erstellen von x-y-diagrammen in OpenOffice.calc

Erstellen von x-y-diagrammen in OpenOffice.calc Erstellen von x-y-diagrammen in OpenOffice.calc In dieser kleinen Anleitung geht es nur darum, aus einer bestehenden Tabelle ein x-y-diagramm zu erzeugen. D.h. es müssen in der Tabelle mindestens zwei

Mehr

Universal Gleismauer Set von SB4 mit Tauschtextur u. integrierten Gleismauerabschlüssen!

Universal Gleismauer Set von SB4 mit Tauschtextur u. integrierten Gleismauerabschlüssen! Stefan Böttner (SB4) März 2013 Universal Gleismauer Set von SB4 mit Tauschtextur u. integrierten Gleismauerabschlüssen! Verwendbar ab EEP7.5(mitPlugin5) + EEP8 + EEP9 Abmessung: (B 12m x H 12m) Die Einsatzhöhe

Mehr

Grundlagen der Informatik. Prof. Dr. Stefan Enderle NTA Isny

Grundlagen der Informatik. Prof. Dr. Stefan Enderle NTA Isny Grundlagen der Informatik Prof. Dr. Stefan Enderle NTA Isny 2 Datenstrukturen 2.1 Einführung Syntax: Definition einer formalen Grammatik, um Regeln einer formalen Sprache (Programmiersprache) festzulegen.

Mehr

1 Einleitung. Lernziele. automatische Antworten bei Abwesenheit senden. Einstellungen für automatische Antworten Lerndauer. 4 Minuten.

1 Einleitung. Lernziele. automatische Antworten bei Abwesenheit senden. Einstellungen für automatische Antworten Lerndauer. 4 Minuten. 1 Einleitung Lernziele automatische Antworten bei Abwesenheit senden Einstellungen für automatische Antworten Lerndauer 4 Minuten Seite 1 von 18 2 Antworten bei Abwesenheit senden» Outlook kann während

Mehr

Anleitung zur Installation des Printservers

Anleitung zur Installation des Printservers Anleitung zur Installation des Printservers 1. Greifen Sie per Webbrowser auf die Konfiguration des DIR-320 zu. Die Standard Adresse ist http://192.168.0.1. 2. Im Auslieferungszustand ist auf die Konfiguration

Mehr

Handbuch. NAFI Online-Spezial. Kunden- / Datenverwaltung. 1. Auflage. (Stand: 24.09.2014)

Handbuch. NAFI Online-Spezial. Kunden- / Datenverwaltung. 1. Auflage. (Stand: 24.09.2014) Handbuch NAFI Online-Spezial 1. Auflage (Stand: 24.09.2014) Copyright 2016 by NAFI GmbH Unerlaubte Vervielfältigungen sind untersagt! Inhaltsangabe Einleitung... 3 Kundenauswahl... 3 Kunde hinzufügen...

Mehr

Hilfe Bearbeitung von Rahmenleistungsverzeichnissen

Hilfe Bearbeitung von Rahmenleistungsverzeichnissen Hilfe Bearbeitung von Rahmenleistungsverzeichnissen Allgemeine Hinweise Inhaltsverzeichnis 1 Allgemeine Hinweise... 3 1.1 Grundlagen...3 1.2 Erstellen und Bearbeiten eines Rahmen-Leistungsverzeichnisses...

Mehr

Anbindung des eibport an das Internet

Anbindung des eibport an das Internet Anbindung des eibport an das Internet Ein eibport wird mit einem lokalen Router mit dem Internet verbunden. Um den eibport über diesen Router zu erreichen, muss die externe IP-Adresse des Routers bekannt

Mehr

MCRServlet Table of contents

MCRServlet Table of contents Table of contents 1 Das Zusammenspiel der Servlets mit dem MCRServlet... 2 1 Das Zusammenspiel der Servlets mit dem MCRServlet Als übergeordnetes Servlet mit einigen grundlegenden Funktionalitäten dient

Mehr

Nullserie zur Prüfungsvorbereitung

Nullserie zur Prüfungsvorbereitung Nullserie zur Prüfungsvorbereitung Die folgenden Hilfsmittel und Bedingungen sind an der Prüfung zu beachten. Erlaubte Hilfsmittel Beliebiger Taschenrechner (Der Einsatz von Lösungs- und Hilfsprogrammen

Mehr

Austausch- bzw. Übergangsprozesse und Gleichgewichtsverteilungen

Austausch- bzw. Übergangsprozesse und Gleichgewichtsverteilungen Austausch- bzw. Übergangsrozesse und Gleichgewichtsverteilungen Wir betrachten ein System mit verschiedenen Zuständen, zwischen denen ein Austausch stattfinden kann. Etwa soziale Schichten in einer Gesellschaft:

Mehr