Leopold-Franzens-Universität Innsbruck Institut für Informatik Datenbanken und Informationssysteme Delta-Kodierung im B-Baum Bachelor-Arbeit von Thomas Meindl betreut von Dipl.-Ing. Robert Binna und Prof. Dr. Günther Specht Innsbruck, 26. Oktober 2013 Zusammenfassung Effiziente B-Baum Implementierungen erfordern einen hohen Verzweigungsgrad und sind hinsichtlich der Eigenschaften des CPU-Caches optimiert. Diese Arbeit beschreibt, wie sich auf Grundlage der Delta-Kodierung eine Datenkompression für die B-Baum-Datenstruktur realisieren lässt. Die dafür notwendigen Modifikationen der Algorithmen und daraus resultierenden Auswirkungen bezüglich des Performanceverhaltens werden erörtert. Anschließend erfolgt eine Darstellung verschieden strukturierter Knoten mit ihren spezifischen Eigenschaften. In der Evaluierung zeigt sich das Verhalten der unterschiedlichen inneren Strukturen in Verbindung mit dem in dieser Arbeit beschriebenen Delta-kodierten B-Baum. Diese B-Baum Varianten werden mit dem konventionellen BBaum und den Rot-Schwarz Suchbäumen des Java TreeSets und des C++ STL-Sets verglichen. Dabei ist ihr Speicherbedarf geringer und sie erreichen annähernd die Leistungsfähigkeit des TreeSets. Abstract Efficient B-tree implementations provide a high fan-out rate and are optimized in respect to the properties of the CPU-cache. On the basis of the so called delta-encoding, this thesis presents a data compression scheme for B-trees. It describes the modifications that are needed for the algorithms and the effects in regard to the performance of the proposed data-structure. Additionally, this paper introduces different node structures and discusses their properties. An evaluation compares the behavior of the proposed inner structures for the presented B-tree type and compares it with the conventional B-Tree and the Red-Black trees provided by the TreeSet of Java and STL Set of C++. This shows that delta-encoded B-trees consume less memory and are able to provide almost the same performance as the Java TreeSet. Inhaltsverzeichnis 1 Einleitung 1 2 Relevante Arbeiten 3 2.1 B-Baum Datenstrukturen . . . . . . . . . . . . . . . . . . 3 2.2 Arbeiten mit Schwerpunkt Performance . . . . . . . . . . 5 3 Delta-Kodierung im B-Baum 3.1 3.2 7 Der Delta-kodierte B-Baum . . . . . . . . . . . . . . . . . 7 3.1.1 Grundlagen und Definition . . . . . . . . . . . . . 7 3.1.2 Die Suche im Baum . . . . . . . . . . . . . . . . . 9 3.1.3 Einfügen von Schlüsseln . . . . . . . . . . . . . . . 11 3.1.4 Umverteilung von Schlüsseln 3.1.5 Löschen von Schlüsseln . . . . . . . . . . . . . . . . 15 3.1.6 Zusammenfassung der Operationen . . . . . . . . . 17 . . . . . . . . . . . . 13 Die Knotenarchitektur des B-Baumes . . . . . . . . . . . . 19 4 Evaluierung 23 4.1 Testumgebung 4.2 Methodik der Benchmarks . . . . . . . . . . . . . . . . . . 24 4.3 Der Speicherverbrauch . . . . . . . . . . . . . . . . . . . . 24 4.4 . . . . . . . . . . . . . . . . . . . . . . . . 23 4.3.1 Verbrauch beim Aufbau . . . . . . . . . . . . . . . 25 4.3.2 Verbrauch nach dem Aufbau . . . . . . . . . . . . 26 Benchmarks über Einfügeoperationen . . . . . . . . . . . . 28 4.4.1 Menge aus direkt aufeinanderfolgenden Schlüsseln . 28 4.4.2 Randomisierte Schlüsselmengen . . . . . . . . . . . 28 4.4.3 Rein zufällige Schlüsselmenge . . . . . . . . . . . . 30 4.5 Benchmarks über Suchvorgänge . . . . . . . . . . . . . . . 30 4.6 Zusammenfassung der Ergebnisse . . . . . . . . . . . . . . 33 III INHALTSVERZEICHNIS 5 Zusammenfassung und Ausblick 35 Appendix 37 A.1 Compilieren des HotSpot-Disassemblers . . . . . . . . . . 37 A.2 Löschen nach Cormen et al. . . . . . . . . . . . . . . . . . 38 A.3 Der Delta-kodierte B+ Baum . . . . . . . . . . . . . . . . 39 A.3.1 Einfügen von Schlüsseln . . . . . . . . . . . . . . . 39 A.3.2 Löschen von Schlüsseln . . . . . . . . . . . . . . . . 40 Literaturverzeichnis IV 42 Kapitel 1 Einleitung Die Leistung von DBMS wird durch die zugrundeliegenden Indexstrukturen bestimmt. Die Ausführungsgeschwindigkeit dieser Schicht lässt sich optimieren, indem Datenstrukturen auf die verwendete Hardware abgestimmt werden. So erlauben B-Bäume eine effiziente Verwaltung von Datensätzen auf Festplatten, da ihr innerer Aufbau bestmöglich an die Blockgröße der Massenspeicher angepasst ist. Durch die stete Zunahme der Hauptspeichergröße können Datenbanken wortwörtlich „In-Memory“ gehalten werden. Aufgrund der Anpassung an die Seitengröße des Hauptspeichers dienen B-Bäume hier ebenso als effiziente Hauptspeicherdatenstruktur. Da sich die Geschwindigkeit von Mikroprozessor und Hauptspeicher in Größenordnungen unterscheidet, wird das Laufzeitverhalten von speicherintensiven Anwendungen ausschließlich durch eine Cache-Hierarchie verbessert. Für eine hohe Performance muss daher die innere Struktur des B-Baumes mit der Größe der Cache-Line in Übereinstimmung gebracht werden. Mithilfe von Kompressionstechniken lassen sich Daten in einem Knoten platzsparender unterbringen. Speziell bei 64-Bit Architekturen ist es vorteilhaft, den Platzbedarf für Zeiger einzuschränken, damit dadurch eine bestmögliche Nutzung der Cache-Zeile möglich wird. Die eben beschriebene Problematik bildet die Motivationsgrundlage der vorliegenden Arbeit. Es wird hier ein Cache-sensitiver B-Baum realisiert, der mit Hilfe verschiedener Verfahren Schlüssel und Zeiger mit reduziertem Speicherbedarf in den Knoten ablegen kann. Er ist damit dem klassischen B-Baum in Hinsicht auf den Speicherverbrauch überlegen. Die Gliederung der Arbeit gestaltet sich wie folgt: In Kapitel 2 werden relevante Arbeiten zu herkömmlichen und Cache-sensitiven B und B+ Bäumen vorgestellt. Kapitel 3 zeigt die Grundsätze der Datenreduktion durch kürzere Schlüs- 1 KAPITEL 1. EINLEITUNG sel für den hier vorgestellten B-Baum und erörtert die Änderungen an den Algorithmen. Des Weiteren werden verschiedene inneren Strukturen für Knoten besprochen. Kapitel 4 behandelt die Evaluierung der Datenstrukturen und diskutiert Unterschiede zu den Standardlösungen von Java und C++. Die Arbeit schließt mit einer Zusammenfassung und einem Ausblick in Kapitel 5. 2 Kapitel 2 Relevante Arbeiten 2.1 B-Baum Datenstrukturen Diese Sektion stellt relevante Arbeiten zu B-Baum Datenstrukturen vor. Obwohl einige davon ausschließlich B+ Bäume behandeln, lassen sich die Ergebnisse auf B-Bäume übertragen. Beide Strukturen werden in den folgenden Abschnitten synonym verwendet. B-Bäume werden in Organization and Maintenance of Large Ordered Indices von Rudolf Bayer und Edward M. McCreight [BM70] erstmals für die Organisation von Daten vorgeschlagen. Eine umfassende Übersicht von B, B*, B+ und Präfix-B-Bäumen findet sich in The Ubiquitous BTree von Douglas Comer [Com79]. Im Artikel Making B+-Trees Cache Conscious in Main Memory stellen Rao und Ross [RR00] mit dem cache sensitive B+ tree eine für den Cache optimierte Variante vor. Die Autoren vergleichen die Leistung mit von ihnen selbst in [RR99] vorgeschlagenen Cache Sensitive Search Trees. CSS sind auf Arrays basierende binäre Suchbäume, die lediglich Schlüssel, aber keine Zeiger speichern. Gesuchte Schlüssel lassen sich durch arithmetische Berechnungen finden. Sie unterstützen keine inkrementellen Updates und müssen in Folge einer Änderung neu aufgebaut werden. Dieser aufwändigen Operation steht ein Geschwindigkeitsvorteil bei Suchvorgängen gegenüber. Um B+ Bäume Cache-sensitiv zu gestalten, wird ein ähnliches Vorgehen zusammen mit dieser sogenannten pointer elimination Technik vorgestellt: Ein Knoten enthält einen einzigen Zeiger auf eine Gruppe von weiteren Knoten. Über diesen lässt sich zusammen mit der Schlüsselposition die Adresse des Folgeknotens berechnen. Der Trade-Off für die eingesparten Zeiger ist ein wesentlich komplexerer und in Hinsicht auf die Speicherallokierung aufwändigerer Split. Als Vereinfachung werden 3 KAPITEL 2. RELEVANTE ARBEITEN Strategien zur Segmentierung von Knotengruppen und vorzeitiger Speicherreservierung präsentiert. Der Cache-sensitive B+ Baum verwendet zusätzlich eine lazy-deletion. Dabei werden Verschmelzungen von Knoten solange wie möglich verzögert; es gibt beim Entfernen keinen Unterlauf, stattdessen wird der Knoten erst gelöscht, wenn er vollständig leer ist. In Improving Index Performance through Prefetching schlagen Chen et al. [CGM01] aufbauend auf der Arbeit von Rao und Ross vor, Knoten größer als eine Cache-Line zu definieren. Für Sekundärspeichermedien konzipierte Bäume können analog dazu größere Knotenkapazitäten als die dort eingesetzte „natürliche“ Blockgröße aufweisen. Des Weiteren beschreibt die Arbeit Auswirkungen der in den Mikroprozessoren realisierten Prefetching-Mechanismen. So lässt sich ein in zwei Hälften segmentierter Knoten durch spezielle Befehle parallel in den Cache laden. Cache-Misses können damit zwar nicht verhindert, aber besser verdeckt werden und verbessern deshalb die Gesamtperformance. Um eine höhere Lokalität der Schlüssel zu erreichen und sie damit für die binäre Suche zu optimieren, werden sie zu einem eigenen Bereich zusammengezogen; zugehörigen Zeiger sind dementsprechend in einem unmittelbar darauf folgenden Abschnitt gespeichert. Im Speziellen zeigt die Arbeit, wie sich Blattknoten von B+ Bäumen für das Prefetching optimieren lassen. Um Bereichsabfragen zu beschleunigen verwenden die Autoren ein externes Array bestehend aus Zeigern, die auf Blätter verweisen. Noch während die Daten in einen Puffer geladen werden, kann damit das Prefetching für das nächste Blatt angestoßen werden. Das Array bedeutet einen zusätzlichen Verwaltungsaufwand beim Einfügen und Löschen von Blattknoten, welcher mit verschiedenen Optimierungen gering gehalten wird. Laut Chen et al. soll der prefetch B+ tree genannte Baum schnellere Index-Updates und Suchvorgänge ermöglichen, als die ausschließlich auf den Cache-optimierte B+ Variante von Rao und Ross. Das durch die Traversierung vom Eltern- zum Kindknoten entstehende pointer-chasing Problem wird von Chen et al. kurz diskutiert: Es ist für einen B-Baum im Allgemeinen nicht möglich Kindknoten sinnvoll auf Verdacht zu laden, denn erst durch die Suche im Knoten wird festgestellt welcher Bereich als nächstes von Bedeutung ist. Um diesen Effekt zu minimieren und den Baum flacher gestalten zu können, wird eine Kompression für Schlüssel vorgeschlagen (aber nicht näher ausgeführt). Die Arbeit Cache Conscious Trees on Modern Microprocessors von Lee et al. [LLSL10] vergleicht das Performanceverhalten von T und B+ Bäumen und ihren Cache-sensitiven Varianten CST und CSB+ auf modernen Mehrkernprozessoren. Bei dieser Art von Mikroprozessor kommt ein 4 KAPITEL 2. RELEVANTE ARBEITEN weiteres von den Kernen geteiltes Cache-Level hinzu. Laut den Autoren sind die Cache-optimierten Baumvarianten erwartungsgemäß schneller. Darüber hinaus würden CST-Bäume bei der Suche bessere Performancewerte als CSB+, B+ oder T-Bäume erreichen. In Main-Memory Index Structures with Fixed-Size Partial Keys verfasst von Bohannon et al. [BMR01] werden Cache-Misses als Ursache für Performance-Einbrüche, die durch die Traversierung von MehrwegeSuchbäumen entstehen, identifiziert. Laut den Autoren ist es ineffizient, Schlüssel bereits im Index des Baumes zu speichern. Hierbei beziehen sie sich auf T-Bäume nach Lehman et al. [LC86] und Präfix-B-Bäume nach Bayer [BU77]. Mit dem pkT- und pkB-Baum (i.e. partial keys) werden zwei optimierte Varianten vorgestellt, welche partielle Schlüssel für den Index verwenden. Basierend auf der Idee des Präfix-B-Baumes diskutiert die Arbeit eine Methode, mit der sich der Index bei B* und T-Bäumen reduzieren lässt. Dabei können Suchschlüssel eine variable Länge besitzen. In den inneren Knoten werden ausschließlich Teile von Schlüsseln abgelegt, mit denen das Auffinden des richtigen Blattknotens ermöglicht wird. IndexSchlüssel mit einer festen Länge werden Bit für Bit betrachtet. Die erste abweichende Stelle gibt den Ausschlag für eine unterschiedliche Verzweigung. Mit diesem Vorgehen benötigt der Index weniger Speicher als der des entsprechenden T oder B+ Baumes. Darüber hinaus müssen Vergleiche mit dem Suchschlüssel lediglich für den gerade betrachteten Teilschlüssel durchgeführt werden. Der Gebrauch von Schlüsselfragmenten als Separatoren beschränkt den Suchvorgang in den Knoten auf eine lineare Suche. Laut Bohannon et al. lassen sich mit diesem Vorgehen Cache-Misses reduzieren. Als zusätzliche Beschränkung wird der eigentliche Schlüssel nicht direkt im Baum gespeichert, sondern ein auf ihn verweisender Zeiger. Nach der Suchtraversierung ist damit immer noch eine Dereferenzierung (und damit ein zusätzlicher Miss) notwendig, um die eigentlichen Daten zu erhalten. 2.2 Arbeiten mit Schwerpunkt Performance Die folgenden Arbeiten beschreiben Einflussfaktoren bezüglich der Performance von Datenbanksystemen. Ailamaki et al. [ADHW99] untersuchen in DBMSs On A Modern Processor: Where Does Time Go? Ursachen von Geschwindigkeitseinbrüchen bei DBMS. Laut den Autoren wirken sich Level 1 Instruction Stalls und Level 2 Data Stalls am negativsten auf die Performance aus. Die Rate an Level 1 Instruction Misses kann durch zu große Knoten in die Höhe getrieben werden; belegen die Daten zu viel Platz im Cache, kommt es 5 KAPITEL 2. RELEVANTE ARBEITEN zu Verdrängungen. Als weitere Gründe für schlechte Performance, werden die Organisation des Caches, die erhöhte Anzahl von Zugriffen auf Speicherseiten oder der Kontextwechsel des Betriebssystems genannt. Eine genaue Analyse für die Ursachen einer schlechten Performance bei Cache-sensitiven B+ Bäumen bietet Effect of Node Size on the Performance of Cache-Conscious B+ trees von Hankins und Patel [HP03]. Es werden vier grundlegende Faktoren analytisch und experimentell identifiziert, auf die es bei der Implementierung zu achten gilt. Sie umfassen die Punkte Level 2 Data Cache Misses, Anzahl von Instruktionen, Branch Prediction und Translation Lookaside Buffer. Level 2 Data Cache Misses werden durch die binäre Suche in Zusammenspiel mit der Knotengröße erzeugt. Die Aspekte Anzahl der Instruktionen und Branch Prediction beziehen sich auf den gesamten Suchalgorithmus, welcher sowohl den Code für die Suche im Knoten als auch für die Traversierung des Baumes beinhaltet. Der letzte Faktor untersucht den Einfluss des TLB-Caches auf die beim B-Baum unvermeidlichen Seitenfehler bei der Verwaltung von virtuellem Speicher. Die Autoren kommen unter Berücksichtigung der erwähnten Umstände zu dem Schluss, dass eine Cache-Line nicht die optimale Größe eines Knotens darstellt. Um negative Effekte zu minimieren, kann die Kapazität der Knoten diese weitaus übersteigen. Für einen CSB+ Baum werden zum Beispiel 160 Byte empfohlen; je nach Architektur des Mikroprozessors kann sie 512 bis über 1024 Bytes betragen und muss nicht an einer 2er Potenz festgemacht werden. 6 Kapitel 3 Delta-Kodierung im B-Baum Bereits in The Ubiquitous B-Tree von Douglas Comer [Com79] wird die Komprimierung der Zeiger als mögliche Optimierung von B-Bäumen vorgeschlagen. Um ihre Größe in den Knoten zu verringern, werden sie in Abhängigkeit zu einem Offset gespeichert. Der damit eingesparte Platz ermöglicht es, mehr Schlüssel-Zeiger-Paare unterzubringen und einen höheren Verzweigungsgrad zu realisieren. Als weitere Konsequenz reduziert sich die Höhe des Baumes. Bei einer Suche müssen weniger Stufen abgearbeitet werden und die Suchperformance steigt. Diese Arbeit beschreibt, wie sich ein ähnliches Prinzip der Komprimierung auf Schlüssel übertragen lässt. Durch eine vom Elternknoten abhängige Delta-Kodierung lassen sich in einem ersten Schritt kleinere Schlüsselteile berechnen. Anschließend kann - im Vergleich zum Speicherverbrauch bei Verwendung von primitiven Datentypen - durch eine längenkodierte Speicherung Platz eingespart werden, wenn ausschließlich die tatsächlich erforderlichen Bytes für einen Schlüssel abgelegt werden. Bei den Zeigeradressen ist ein analoges Vorgehen möglich. Eine Grundvoraussetzung für die in dieser Arbeit vorgestellte DeltaKodierung von B-Bäumen ist die Verwendung von Integer-Werten für Schlüssel. 3.1 3.1.1 Der Delta-kodierte B-Baum Grundlagen und Definition Abbildung 3.1 und 3.2 zeigen denselben B-Baum, ohne und mit DeltaKodierung. Die in Abbildung 3.2 an den Kanten notierten Werte stellen die tatsächlichen Schlüssel dar. Sie ergeben sich durch Aufsummierung der Teilschlüssel bei der Traversierung. Diese Werte müssen lediglich temporär gespeichert werden und sind für alle Algorithmen des Delta7 KAPITEL 3. DELTA-KODIERUNG IM B-BAUM kodierten B-Baumes grundlegend. Im Folgenden wird der tatsächliche Schlüsselwert im Elternknoten auch als Offset bezeichnet. Um Änderungen an Algorithmen erklären zu können, werden die tatsächlichen Schlüsselwerte in den Abbildungen der folgenden Abschnitte angefügt. In den Knoten eines Delta-kodierten B-Baumes sind lediglich Differenzen gespeichert (siehe Abb. 3.2). Diese ergeben sich aus der Subtraktion des Offsets und des ursprünglichen Schlüssels. Analog dazu lässt sich jeder Schlüssel eines Delta-kodierten B-Baumes durch Addition des eben definierten Offsets zusammen mit dem Wert im Knoten wieder herstellen. Abbildung 3.1: Unkodierter B-Baum. 0 0 5 10 32 18 18 25 28 32 42 60 Abbildung 3.2: B-Baum mit Delta-Kodierung. An den Kanten sind die tatsächlichen Schlüsselwerte annotiert. Der Delta-kodierte B-Baum wird folgendermaßen definiert: Definition. Ein Delta-kodierter B-Baum ist ein Mehrwege Suchbaum. Für jeden nicht leeren Delta-kodierten B-Baum gilt: • Alle Knoten im Baum besitzen dieselbe vorgegebene Knotengröße. • Jeder Knoten außer der Wurzel ist mindestens bis zur Hälfte gefüllt. • Die Wurzel enthält mindestens einen Schlüssel und ist unkodiert. • Innere Knoten enthalten Schlüssel und Zeiger, welche in variabler Länge abgespeichert werden. • Zeiger von inneren Knoten referenzieren weitere Kindknoten. 8 KAPITEL 3. DELTA-KODIERUNG IM B-BAUM • Blattknoten enthalten lediglich Schlüssel. Diese werden in variabler Länge abgespeichert. • Die Schlüssel jedes Knotens werden als Differenzen zum Wert des Separatorschlüssels des Elternknotens gespeichert. • Blätter befinden sich alle auf demselben Level. Zusätzlich werden für die Korrektheit der in den folgenden Kapiteln beschriebenen Algorithmen zwei implizite Annahmen getroffen: • Ohne Beschränkung der Allgemeinheit hat der dem ersten Zeiger zugeordnete Schlüssel den Wert Null. • Schlüssel verwenden den positiven Zahlenbereich. 3.1.2 Die Suche im Baum Für die Suche wird ein bereits aufgebauter, mehrstufiger B-Baum angenommen. Die sich durch die Kodierung ergebenden Unterschiede werden im Zuge dieses Abschnittes erläutert. Der Pseudocode von Algorithmus 1 beschreibt den Suchvorgang. Wie beim normalen B-Baum beginnt dieser im Wurzelknoten. Als erster Knoten besitzt er kein Offset; alle sich in ihm befindlichen Schlüssel sind unkodiert gespeichert und können ohne Modifikation durchsucht werden. Existiert der gesuchte Schlüssel k dort nicht (und ist die Wurzel kein Blatt), wird dessen Vorgänger gesucht. Dieser Schlüssel wird in der Variable kp gespeichert; der zugehörige Zeiger verweist auf das nächste zu ladende Kind. Durch die Addition von kp zu dem bestehenden Offset in der keyOf f set Variable lässt sich jeder Schlüssel im aktuellen Knoten rekonstruieren. Im Pseudocode wird der Suchschlüssel analog dazu in jedem Durchlauf um den Wert von keyOffset verringert in currentKey gespeichert und kann direkt mit den im Knoten gespeicherten Werten verglichen werden. Der eben beschriebene Ablauf wiederholt sich, bis der Schlüssel gefunden wurde oder die Suche ohne Erfolg in einem Blatt endet. In diesem Fall muss abschließend festgestellt werden, ob der Blattknoten den reduzierten Schlüssel currentKey enthält. 9 KAPITEL 3. DELTA-KODIERUNG IM B-BAUM Algorithmus 1 Suche eines Schlüssels Eingabe: Wurzelknoten des Baumes; gesuchter Schlüssel k. Ausgabe: true wenn sich k im Baum befindet, ansonsten f alse. function contains(k) currentN ode ← root keyOf f set ← 0 while currentN ode ist kein Blatt do currentKey ← k − keyOf f set if currentKey ∈ currentN ode then return true end if kp ← Vorgänger von currentKey in currentN ode keyOf f set ← keyOf f set + kp currentN ode ← Lade Knoten referenziert durch Zeiger von kp end while . Schlüssel wurde in keinem inneren Knoten gefunden currentKey ← k − keyOf f set result ← (currentKey ∈ currentN ode) return result end function Abbildung 3.3 zeigt eine exemplarische Suche nach Algorithmus 1. Es wird dabei angenommen, dass eine erfolglose Suche im aktuellen Knoten den Vorgängerschlüssel zusammen mit dem zugeordneten Zeiger auf das nächste Kind liefert. 1.) 62 in node? 32 32 62 > 32 2.) 30 in node? (i.e. 62 - 32) 42 60 30 > 28 3.) 2 in node? (i.e. 30 - 28) Abbildung 3.3: Suche nach Schlüssel 62. 10 KAPITEL 3. DELTA-KODIERUNG IM B-BAUM 3.1.3 Einfügen von Schlüsseln Einfügen ohne Überlauf Das Einfügen der Schlüssel wird im Folgenden auf Grundlage des in Abschnitt 3.1.2 vorgestellten Suchalgorithmus erörtert. Ein Einfügevorgang bei dem kein Split notwendig ist, wird in Abbildung 3.4 veranschaulicht. Die Schritte 1 bis 3 zeigen die Suche nach jenem Blatt, in dem der dekrementierte Schlüssel einzufügen ist. In jedem Zwischenschritt wird das Offset (der an den Zeigern angegebene Wert) vom Suchschlüssel abgezogen und der nächste Kindknoten bestimmt. Im Blatt wird dieser Wert abschließend eingefügt. Den daraus resultierenden Baum zeigt Abbildung 3.5. a) 1.) insert(51) 2.) insert(19) (51 - 32) 32 32 42 3.) insert(9) (51 - 42) 60 Abbildung 3.4: Einfügen des Schlüssels 51 (ohne Überlauf). b) 32 32 42 60 Abbildung 3.5: Baum nach Einfügen von Schlüssel 51. 11 KAPITEL 3. DELTA-KODIERUNG IM B-BAUM Einfügen mit Überlauf Die Delta-Kodierung macht eine Anpassung des Split-Algorithmus erforderlich. Abbildung 3.6 zeigt alle Phasen des Ablaufs. a) +54 b) +22 (54 - 32) 32 +12 (54 - 42) 32 42 32 kl 32 +k l 10 12 60 km c) d) 32 32 32 ks 42 -k m 12 60 32 42 52 60 Abbildung 3.6: Überlauf mit anschließendem Split. Nachdem der Suchalgorithmus das korrekte Blatt für das Einfügen gefunden hat, kommt es dort zum Überlauf (a und b in Abb. 3.6). Der in der Mitte des Knotens liegende Schlüssel km wird entfernt. Zusammen mit der aus dem Elternknoten stammenden Differenz kl ergibt dieser den neuen, im Elternknoten einzufügende Separator ks (Abb. 3.6c). Um abschließend die Korrektheit des rechten Blattes bezüglich des neuen Separators ks im Elternteil sicher zu stellen, wird von jedem Schlüssel des rechten Teils km subtrahiert. Dieser Schritt wird im Folgenden als Neukodierung des Knotens bezeichnet. Das Resultat der Split-Operation zeigt Abbildung 3.6d. Bei weiteren Überläufen kann sich der Split bis in die Wurzel fortsetzen. Änderungen im Baum bleiben dabei lokal begrenzt; ausschließlich die Neukodierung des neuen rechten Knotens und das Abändern des Separators bezüglich des Offsets des Elternknotens sind erforderlich. 12 KAPITEL 3. DELTA-KODIERUNG IM B-BAUM 3.1.4 Umverteilung von Schlüsseln Mit der Umverteilung (oder kleine Rotation) lässt sich beim Löschen von Schlüsseln aus dem Baum eine aufwändigere große Rotation abwenden. Analog dazu kann die Umverteilung beim Einfügen ein vorzeitiges Splitten verhindern. Die Linksrotation Die Abbildung 3.7a zeigt einen Teilbaum, bei dem das Einfügen in das rechte Blatt einen Überlauf erzeugen würde. Durch das Verschieben seines ersten Schlüssels in den linken Geschwisterknoten ist ein Aufspalten nicht notwendig. a) b) ... ... 22 4 5 10 54 1 Abbildung 3.7: Rotation nach links. Lediglich die zwei Geschwister- und der Elternknoten sind von der Operation betroffen: Der ursprüngliche Separator im Elternknoten wird an die letzte Stelle des linken Geschwisterknotens, korrigiert um das dort geltende Offset, angefügt. Der neue Separator im Elternknoten wird durch Addition des ursprünglichen Separators und des ersten Schlüssels des rechten Geschwisterknotens erzeugt. Abschließend sind dort alle verbliebenen Werte bezüglich dieses neuen Separatorschlüssels zu kodieren. Dabei wird von jedem die Differenz von aktualisiertem und ursprünglichen Separator abgezogen. Abbildung 3.7b zeigt den Teilbaum nach der Rotation. Im Falle der inneren Knoten wird zusätzlich der erste Zeiger des rechten Geschwisterknotens an die letzte Stelle des linken Geschwisterknotens geschoben. Zusätzlich ist bei der Einfüge-Umverteilung noch auf eine korrekte Reihenfolge zu achten: Wird die eben beschriebenen Linksrotation durchgeführt und in Abbildung 3.7a zum Beispiel der Schlüssel 53 eingefügt was dort dem Wert 1 entspricht - enthält der Baum als Resultat Schlüssel in der falschen Anordnung und damit korrupte Daten. 13 KAPITEL 3. DELTA-KODIERUNG IM B-BAUM Die Rechtsrotation Die Rechtsrotation erfolgt analog zu der eben beschriebenen Rotation nach links. Abbildung 3.8a und 3.8b zeigen den Teilbaum vor und nach der Operation. a) ... b) 32 ... 32 19 6 7 8 9 2 3 6 7 8 Abbildung 3.8: Rotation nach rechts. 14 51 1 3 4 KAPITEL 3. DELTA-KODIERUNG IM B-BAUM 3.1.5 Löschen von Schlüsseln Für das Entfernen von Schlüsseln wird eine angepasste Variante des Algorithmus von Bayer [BM70] verwendet. Alternativ lässt sich der von Cormen et al. in [LRSC01] erörterte Algorithmus verwenden (siehe dazu Anhang A.2). Die im Folgenden diskutierten notwendigen Anpassungen lassen sich auf beide Vorgehensweisen direkt adaptieren. Im Abschnitt 3.1.2 wurde eine Suchtraversierung skizziert, mit der sich jener Knoten finden lässt, der den zu löschenden Schlüssel enthält. Der Algorithmus speichert zusätzlich in jedem Schritt das auf den Knoten bezogene Schlüsseloffset. Dies ist für die eventuell notwendig werdenden Reparaturmaßnahmen essentiell. Der Merge Um Schlüssel aus dem Baum zu entfernen, ist zusätzlich zu den bereits beschriebenen Rotationen ein abgeändertes Vorgehen für die Verschmelzung von Knoten erforderlich. a) ... b) ... 10 12 Abbildung 3.9: Teilbaum vor und nach dem Verschmelzen von Knoten. Der Merge ist die reziproke Operation zum Split. Abbildung 3.9a veranschaulicht einen Teilbaum mit Unterlauf. In Umkehrung zum Split wird der Separator aus dem Elternknoten in den linken Kindknoten geschoben. Er muss dabei um die Differenz von rechten und linken Offset der Kindknoten verringert werden. Die Schlüssel des rechtem Kindknotens werden rekodiert und an den linkem Kindknoten angefügt. Bei der Rekodierung wird zu jedem Schlüssel die eben erwähnte Differenz addiert. Abschließend muss der rechte Kindknoten entfernt werden (Abb. 3.9b). 15 KAPITEL 3. DELTA-KODIERUNG IM B-BAUM Löschen aus einem Blatt Wie im normalen B-Baum wird beim Löschen aus einem Blatt der Schlüssel ohne weitere Anpassungen entfernt. Kommt es zu einem Unterlauf, wird ein Ausgleich über den linken oder rechten Geschwisterknoten versucht. Ist dies nicht möglich, wird der Merge wie im vorigen Abschnitt beschrieben durchgeführt. Da die Verschmelzung den ehemaligen Separator in den resultierenden Knoten zieht, kann durch sie eine weitere Unterlaufbehandlung im Elternknoten erforderlich werden. Falls sich dies bis in eine leerlaufende Wurzel fortzieht, sorgt der Verschmelzungs-Algorithmus implizit für einen unkodierten neuen Wurzelknoten. Löschen aus einem inneren Knoten Für das Löschen aus einem inneren Knoten ist ein komplexeres Vorgehen als beim B-Baum erforderlich. Der zu entfernende Schlüssel besitzt sowohl die Funktion eines Separators als auch eines Offsets. Daher wird mindestens ein Knoten neu kodiert. Wie beim normalen B-Baum wird der Schlüssel durch seinen symmetrischen Vorgänger oder Nachfolger ersetzt. ... a) ... b) 32 32 11 ... 42 42 4 8 46 50 ... ... 43 3 7 43 1 2 46 50 ... Abbildung 3.10: a) Zeigt den Baum vor Löschen des Schlüssels 42 (entspricht dem Wert 10). Wie in b) ersichtlich, sind Rekodierungen lediglich für die Wurzel und jeden am weitest links stehenden Knoten des Subbaumes notwendig. Algorithmus 2 auf Seite 18 beschreibt den Löschvorgang. Nach dem Fund des zu entfernenden Schlüssels in einem inneren Knoten (Fall 2 von Algorithmus 2) erfolgt der Abstieg in das erste Blatt des rechten Subbaumes. Implizit speichert der Algorithmus bei jedem Ladevorgang eine Referenz auf den Vorgängerknoten. Alle am weitest links stehenden Knoten des Subbaumes müssen bezüglich des symmetrischen Nachfolgers neu 16 KAPITEL 3. DELTA-KODIERUNG IM B-BAUM kodiert werden, da sie dasselbe Offset besitzen und deshalb vom zu löschenden Schlüssel abhängig sind. Dies geschieht nach dem Entfernen des ersten Schlüssels aus dem Blatt, beim Aufstieg zurück in den SubbaumWurzelknoten. Abbildung 3.10 zeigt ein Beispiel des eben beschriebenen Ablaufes. Abschließend wird im Falle eines Unterlaufes im Blatt, ein (wie im Abschnitt Löschen aus einem Blatt geschilderter) Korrekturvorgang durchgeführt. Wird hingegen der symmetrische Vorgänger als Ersatz für den zu löschenden Schlüssel gewählt, erfolgt eine Suchtraversierung über den linken Subbaum (ohne Rekodierungen). Der zu entfernende Separator wird durch den letzten Schlüssel des letzten Blattes ersetzt. Damit wird ein Abstieg in den rechten Teil, zusammen mit einer Rekodierung aller ersten Knoten erforderlich. Daher bedeutet das Löschen mit Hilfe des symmetrischen Vorgängers eine Traversierung über zwei Subbäume. Damit verbunden ist ein durch das Laden von zusätzlichen Knoten verursachter Mehraufwand. 3.1.6 Zusammenfassung der Operationen In den letzten Kapiteln wurden die Unterschiede des Delta-kodierten B-Baumes zum herkömmlichen B-Baum herausgearbeitet. Ein Suchlauf benötigt das von der Wurzel aus berechnete Offset als zusätzliche Information. Mit diesem Wert können alle Schlüssel des aktuell besuchten Knotens rekonstruiert werden. Das Offset ist für Split, Einfügen neuer Schlüssel und Ausgleichsrotationen essentiell. Es wird durch Split und Rotationen verändert und macht eine Neukodierung eines Knotens erforderlich. Beim Entfernen von Schlüsseln wird beim Verschmelzen eine Umkodierung für den zu integrierenden Knoten notwendig. Während das Löschen aus einem Blatt analog zum B-Baum geschieht, bedarf es beim Entfernen aus dem Inneren der Neukodierung aller ersten Knoten des rechten Subbaumes. 17 KAPITEL 3. DELTA-KODIERUNG IM B-BAUM Algorithmus 2 Löschen aus Blatt- und inneren Knoten Eingabe: Zu entfernender Schlüssel k; Knoten currentN ode in dem dieser sich kodiert befindet; alle Offsets bis zu diesem Knoten; jeder traversierte Knoten kennt seinen Elternknoten. Ausgabe: Modifizierter Baum ohne Schlüssel k. leaf N ode ← null function remove(k, currentNode) currentKey = k − currentN ode.keyOf f set if currentKey ∈ currentN ode then if currentN ode ist ein Blatt then . Fall 1 Entferne currentKey aus currentN ode Repariere von currentN ode aus, falls Unterlauf else . Fall 2 subT reeRoot ← Knoten identifiziert durch currentKey successor ←decend(subT reeRoot ) newSeparator ← successor − currentN ode.KeyOf f set Ersetze (currentKey ∈ currentN ode) durch newSeparator Repariere ausgehend von leaf N ode, falls Unterlauf end if end if end function function Decend(subTreeRoot) . Methode für Abstieg in Subbaum current ← subT reeRoot while current ist kein Blatt do Lade ersten Knoten nach current Speichere Offset und Referenz auf Elternknoten in current end while leaf N ode ← current . globale Variable successor ←(Erster Key in leaf N ode) + leaf N ode.keyOf f set Entferne ersten Schlüssel aus leaf N ode repeat Kodiere current bezüglich successor neu current.keyOf f set ← successor Lade den Elternknoten nach current until current ist nicht der Elternknoten von subT reeRoot return successor end function 18 KAPITEL 3. DELTA-KODIERUNG IM B-BAUM 3.2 Die Knotenarchitektur des B-Baumes Für die Umsetzung des hier vorgestellten B-Baumes wurde eine Schichtenarchitektur gewählt. Mit diesem Prinzip lassen sich sowohl der klassische B-Baum als auch verschiedene Delta-kodierte B-Bäumen erstellen. Wie in Kapitel 3 beschrieben, erfordert die Delta-Kodierung Knoten, welche Daten in variabler Länge ablegen können. Die in den folgenden Abschnitten erörterten Knotenarten mit innerer RB-Baum Struktur, längenkodierter Struktur und längenkodierter Struktur mit Präfixsummen Optimierung besitzen diese Eigenschaft. Dort gespeicherte Schlüssel oder Zeiger verwenden ausschließlich die Anzahl an Bytes, welche für ihre Darstellung erforderlich ist. Die Knoten unterstützen die Speicherung von 64-Bit Integern und verwenden für diese entsprechend mindestens 1 Byte bis maximal 8 Bytes. Klassische B-Baum Struktur Als Vergleichsdatenstruktur wurden Knoten mit einer Array-ähnlichen Organisation implementiert, die fixierte Längen für Schlüssel und Zeiger voraussetzen. Durch sie wird ein klassischer B-Baum realisiert. Die Anzahl der enthaltenen Elemente wird in einem führenden Header abgelegt. Darauffolgend sind alle Schlüssel in sortierter Reihenfolge als 8 Byte lange Werte gespeichert. Blattknoten enthalten lediglich diese beiden Felder. Bei inneren Knoten folgt dieser Anordnung ein unmittelbar anschließender Bereich für Zeiger, die in derselben Reihenfolge wie ihre Schlüssel abgelegt sind (siehe dazu Abb. 3.11). size key1 key2 key3 ... keyn | ptr0 ptr1 ptr2 ptr3 ... ptrn free space Abbildung 3.11: Interner Knoten mit Array Struktur (Schlüssel werden mit keyi , Zeiger mit ptri bezeichnet). Aus Gründen der Übersichtlichkeit wird auf die Darstellung des Blattknotens verzichtet. Eine Ausnahme besteht für den ersten Zeiger: Um den Platzverbrauch für den Schlüssel 0 von 8 Bytes einzusparen, wird dieser als implizit existierend angenommen. Wie beim herkömmlichen B-Baum werden n Schlüssel und n + 1 Zeiger gespeichert. Der erforderliche Platz für Zeiger wird auf 6 Bytes beschränkt. Damit ist ein Speicherbereich von 248 Adressen für Kindknoten möglich. Die eindeutigen Byte-Größen von Schlüsseln und Zeigern erlauben performante Implementierungen von Merge, Split, Rekodierung und Rotati19 KAPITEL 3. DELTA-KODIERUNG IM B-BAUM on. Als Suchalgorithmus in den Knoten wird die binäre Suche verwendet, zumal die beschriebene Datenorganisation einen wahlfreien Zugriff ohne zusätzlichen Aufwand realisiert. Die Array-Struktur hat den offensichtlichen Nachteil, keinen Nutzen aus der Delta-Kodierung des B-Baumes ziehen zu können; es macht keinen Unterschied ob der ursprüngliche oder der verkleinerte Wert verwendet wird. RB-Baum als innere Struktur Mit diesem Knotentyp können sowohl Schlüssel als auch Key-Pointer Paare in einem als Rot-Schwarz Baum organisierten Speicherbereich abgelegt werden. Intern wird für die Speicherung der Werte eine fixed-prefix Kodierung benutzt, mit der lediglich der tatsächlich erforderliche Platz kodiert wird. Durch den RB-Baum lässt sich die Suche performant realisieren. size free space k0 k1 k3 k 2 nil nil nil nil nil k6 k4 k5 nil nil k1 k7 nil nil k8 k9 k 10 k 11 nil nil nil nil Abbildung 3.12: Red-Black Tree als innere Knotenstruktur. Eine Besonderheit ergibt sich bei der Ausgleichsrotation nach links: Hier kann unter bestimmten Voraussetzungen der aktualisierte Separator im Elternknoten nicht gespeichert werden. Durch diese Rotation ergibt sich ein höherer Betrag des Schlüssels und damit in manchen Fällen ein größerer Platzbedarf. Bei einem vollen Knoten wird in diesem Zusammenhang ein zusätzlicher Split notwendig. Wie in Kapitel 3.1.4 beschrieben, kodiert die Rechtsrotation in jedem Rotationsschritt den gesamten rechte Knoten neu. Dabei vergrößern sich die Schlüssel im Betrag und benötigen analog zur Linksrotation unter Umständen mehr Platz im Knoten als vorhandenen ist. In diesem Fall stellt ein Rollback-Algorithmus die Konsistenz der Daten wieder her. Beim Merge erfolgt eine Abschätzung für eine erfolgreiche Durchführung der Operation. Wieder setzt ein Rollback den Knoten in den Ursprungs20 KAPITEL 3. DELTA-KODIERUNG IM B-BAUM zustand zurück, sollte dabei zu wenig Speicher im Zielknoten zur Verfügung stehen. Wie in Kapitel 3.1.5 beschrieben, erfordert eine große Rotation einige Umkodierungen für den rechten Subbaum. Ein Problem bezüglich der Rekodierung in Zusammenhang mit zu wenig Speicherplatz tritt hier nicht auf; alle notwendigen Berechnungen haben kleinere Werte in den Knoten zum Resultat. Entsprechend wie beim Split kann zu wenig Platz für den neue Separator im Elternknoten vorhanden sein, was sich durch ein erneutes Aufspalten korrigieren lässt. Knoten mit längenkodierten Daten Der Platzbedarf von Schlüssel und Zeiger wird bei dieser Knotenart in sogenannten Subheadern gespeichert. Wie in Abbildung 3.13 zu sehen ist, kodieren 3 Bits die Längeninformation (wobei 0 für die Länge von einem Byte steht). Schlüssel und Zeiger werden vom Ende des zur Verfügung stehenden Speicherbereichs aus geschrieben. Beim Einfügen wachsen daher Subheader und Einträge innerhalb eines Knotens aufeinander zu. size subheader0 ... subheadern highest bits 23 22 21 20 19 18 17 16 15 subheader 14 13 12 11 10 09 08 07 06 05 04 03 free space ptrn keyn ... ptr1 key1 ptr0 key0 lowest bits 02 01 00 000 000 000 000 000 000 000 000 byte 0 byte 1 byte 2 Abbildung 3.13: Längenkodierung von Knotendaten realisiert durch Subheader. Split und Rotationen verhalten sich analog zu den Knoten mit RBBaum Struktur. Der Merge besitzt eine exakte Berechnungsmethode für die Vorhersage seiner Durchführbarkeit (und benötigt keinen RollbackMechanismus). Als Einschränkung wird bei diesem Knotentyp ausschließlich eine lineare Suche verwendet. Deswegen folgt bei inneren Knoten jedem Schlüssel unmittelbar der zugehörige Zeiger. Längenkodierung kombiniert mit Präfixsummen Dieser Knotentyp erlaubt es, die binäre Suche zusammen mit der Längenkodierung von Werten zu verwenden. Die Längeninformationen werden in Subheadern nach einem, für die Berechnung von Präfixsummen optimierten Schema verteilt. Beim Einlesen des Knotens erfolgt die Speicherung 21 KAPITEL 3. DELTA-KODIERUNG IM B-BAUM aller Teilsummen in einem temporären Array. Damit lässt sich die Position eines Schlüssels oder Zeigers im Speicherbereich direkt aus dem Feld auslesen. Durch die jeweils nachfolgende Teilsumme wird anschließend die Größe berechnet. size subheader0 ... subheadern highest bits 63 60 57 54 51 48 45 42 39 free space subheader 36 33 30 27 24 ptrn ... ptr1 ptr0 | keyn ... key1 key0 21 18 15 12 9 6 lowest bits 3 0 u000 111 000 111 000 111 000 111 000 111 000 111 000 111 000 111 000 111 000 111 000 bits 06 05 04 03 02 01 00 slot 0 - 6 13 12 11 10 09 08 07 slot 7 - 13 19 18 17 16 15 14 slot 14 - 20 20 byte 7 byte 6 byte 5 byte 4 byte 3 byte 2 byte 1 byte 0 Abbildung 3.14: Eine optimierte Verteilung der Längeninformationen ermöglicht die performante Berechnung von Präfixsummen. Die Längen von Schlüsseln (oder Zeigern) werden in „Slots“ von 3 Bits gespeichert. Wie beim vorhergehenden Knotentyp werden Schlüssel und Zeiger vom Ende des Knotens aus gespeichert. Schlüssel werden in Hinsicht auf die binäre Suche nacheinander abgelegt; darauf folgt der Bereich für die Zeiger. Die Operationen Merge, Split, Rotation und Rekodierung verhalten sich völlig analog zu dem im vorigen Abschnitt vorgestellten Knotentyp. Zusammenfassung der Knotenarten Mithilfe der in diesem Abschnitt beschriebenen Knotentypen werden Delta-kodierte B-Bäume, mit voneinander abweichenden inneren Strukturen realisiert. Bei den als Arrays organisierten Knoten ist es durch die fixierte Länge der gespeicherten Daten nicht möglich, die Datenkompression zu implementieren. Dies erlaubt eine über Rot-Schwarz Bäume umgesetzte innere Struktur, eine längenkodierte Speicherung oder eine für Präfixsummen optimierte Abwandlung. 22 Kapitel 4 Evaluierung Für die Evaluierung wurde ein B-Baum mit Delta-Kodierung, zusammen mit allen im Kapitel 3.2 vorgestellten Knotenstrukturen in Java implementiert. Die Speicher-Allokierung für Knoten wird von einem eigenen Speichermanager realisiert. Dieser erlaubt einen direkten Zugriff auf den Systemspeicher, ohne Verwendung des automatischen Speichermanagements von Java. Es wurde eine Benchmarksuite erstellt, mit der eine Gegenüberstellung von unterschiedlichen Delta-kodierten B-Baum Typen bezüglich Speicherverbrauch und Geschwindigkeit möglich ist. Darüber hinaus stellt diese Vergleiche mit dem herkömmlichen B-Baum, dem Java TreeSet und dem C++ STL-Set in Hinblick auf Einfüge- und Suchvorgänge an. 4.1 Testumgebung Die Tests wurden auf einem 8-Kern AMD FX-8350 Prozessor mit 4 GHz und 16 GB Hauptspeicher durchgeführt. Die Cache-Line dieser 64-Bit CPU ist 64 Byte lang. Level 1 Data Cache und Level 1 Instruction Cache besitzen jeweils eine Gesamtgröße von 128 KB und 256 KB. Bei diesem Mikroprozessor sind jeweils zwei Kerne zu einem sogenannten Modul zusammengefasst, wobei jedes über einen Level 2 Cache von 2048 KB verfügt. Alle Kerne teilen sich einen 8 MB großen Level 3 Cache. Als Betriebssystem kam openSUSE 12.3 mit Linux Kernel 3.7.10 zum Einsatz. Für die Ausführung der Java Programme wurde das Oracle OpenJDK Version 1.7.0 Update 40 verwendet. Über die VM-Optionen -disableassertions -XX:MaxDirectMemorySize=2g -Xmx10g wurde ausreichend Speicher für die Vergleichstests zur Verfügung gestellt. Für die Übersetzung der C-Programme wurde g++ der GNU Compiler Collection Version 4.7.2 verwendet. Die Quellen wurden mit der Kompileroption -O3 umgewandelt. 23 KAPITEL 4. EVALUIERUNG 4.2 Methodik der Benchmarks Es werden der Speicherverbrauch und die Leistung bei Aufbau und Suche auf einem einzelnen CPU-Kern untersucht. Die Aufbaubenchmarks erfassen die Leistung für das Einfügen einer definierten Menge von Schlüsseln in einen leeren Baum. Die Leistungsmessung des Suchvorgangs erfolgt anschließend unter Verwendung derselben Menge. Beide Schritte werden sowohl für Delta-kodierte B-Bäume als auch für B-Baum, TreeSet und STL-Set für das Intervall von 100.000 bis 12 Millionen Schlüssel durchgeführt. Die betrachteten B-Bäume werden in den nachfolgenden Ausführungen in Übereinstimmung mit ihren inneren Knotenstrukturen B-Baum, RedBlack-, Längenkodierter- und Präfix-Summen δ-B-Baum genannt. 4.3 Der Speicherverbrauch Die in dieser Arbeit beschriebene Kodierung ist für den Speicherverbrauch von Vorteil, falls die Schlüsselverteilung eine Kompression erlaubt: Liegen die Schlüssel in einem Wertebereich nahe beieinander, bilden sie kleine Differenzen und können effizient im Baum gespeichert werden. Sind sie zufällig über den gesamten Wertebereich verteilt, ergeben sich große Differenzen, welche im Worst Case gleich viel Speicherplatz wie die ursprünglichen Schlüssel verbrauchen. Der Speicherverbrauch für B-Bäume wird über den eigenen Speichermanager festgestellt. Beim TreeSet erfolgt dies über Java-Runtime Methoden. Um möglichst genaue Resultate zu erhalten, wird der Garbage Collector vor jedem Durchgang mehrfach aufgerufen. 24 KAPITEL 4. EVALUIERUNG 4.3.1 Verbrauch beim Aufbau Abbildung 4.1 zeigt den für ein lineares Einfügen von Schlüsseln notwendigen Speicherbedarf. Auf der y-Achse des Diagramms ist der Verbrauch in Megabyte angegeben; die Anzahl der Schlüssel ist auf der x-Achse aufgetragen. Für das TreeSet ist hierbei zusätzlicher Speicher notwendig, der erst nach Abschluss der Operation wieder freigegeben wird. 800 MB 700 MB 600 MB B-Tree RB δB-Tree Längenkodierter δB-Tree Präfix Summen δB-Tree Java TreeSet 500 MB 400 MB 300 MB 200 MB 100 MB 0 MB 0 Mio. 2 Mio. 4 Mio. 6 Mio. Schlüssel 8 Mio. 10 Mio. 12 Mio. Abbildung 4.1: Speicherverbrauch beim Aufbau der Bäume (unter Verwendung einer lineare Schlüsselmenge). 25 KAPITEL 4. EVALUIERUNG 4.3.2 Verbrauch nach dem Aufbau Beim Suchvorgang wird ausschließlich der tatsächliche Verbrauch in Abhängigkeit zur verwendeten Schlüsselmenge gemessen. Die Abbildungen 4.2 bis 4.4 zeigen den Speicherverbrauch für verschiedene Mengen. Menge aus direkt aufeinanderfolgenden Schlüsseln Abbildung 4.2 zeigt den Speicherbedarf für eine lineare Menge von Schlüsseln. Direkt aufeinanderfolgende Schlüssel sind für die Delta-Kodierung optimal. Wie in der Abbildung zu sehen ist, reduziert sich der Speicherbedarf für alle δ-B-Bäume im Vergleich zum B-Baum um 2/3. Das TreeSet hat gegenüber jedem δ-B-Baum einen bis zu 8-fach höheren Speicherverbrauch. 300 MB B-Tree RB δB-Tree 250 MB Längenkodierter δB-Tree Präfix Summen δB-Tree Java TreeSet 200 MB 150 MB 100 MB 50 MB 0 MB 0 Mio. 2 Mio. 4 Mio. 6 Mio. Schlüssel 8 Mio. 10 Mio. Abbildung 4.2: Speicherverbrauch linearer Schlüsselmengen. 26 12 Mio. KAPITEL 4. EVALUIERUNG Randomisierte Schlüsselmengen Abbildung 4.3 zeigt den Speicherverbrauch einer Menge „nahe“ aufeinanderfolgender Schlüssel; jeder Schlüssel besitzt einen zufälligen Abstand von maximal 1000 zu seinem Vorgänger. In diesem Fall benötigen der Längenkodierte- und Präfix-Summen δ-B-Baum 3/4 des Speicherbedarfes des klassischen B-Baumes. 300 MB B-Tree RB δB-Tree 250 MB Längenkodierter δB-Tree Präfix Summen δB-Tree Java TreeSet 200 MB 150 MB 100 MB 50 MB 0 MB 0 Mio. 2 Mio. 4 Mio. 6 Mio. Schlüssel 8 Mio. 10 Mio. 12 Mio. Abbildung 4.3: Speicherverbrauch „nahe“ aufeinanderfolgender Schlüssel. 300 MB B-Tree RB δB-Tree 250 MB Längenkodierter δB-Tree Präfix Summen δB-Tree Java TreeSet 200 MB 150 MB 100 MB 50 MB 0 MB 0 Mio. 2 Mio. 4 Mio. 6 Mio. Schlüssel 8 Mio. 10 Mio. 12 Mio. Abbildung 4.4: Speicherverbrauch zufällig verteilter Schlüssel. 27 KAPITEL 4. EVALUIERUNG Abbildung 4.4 zeigt den Speicherverbrauch einer Menge von zufälligen Schlüsseln aus dem Intervall [0; 263 − 2]. Wie am Anfang des Abschnittes beschrieben wurde, ist diese Situation für Delta-kodierte B-Bäume ungünstig. Deshalb nähert sich der Verbrauch der δ-B-Bäume dem klassischen B-Baum an. 4.4 Benchmarks über Einfügeoperationen 4.4.1 Menge aus direkt aufeinanderfolgenden Schlüsseln Abbildung 4.5 zeigt die Ergebnisse für ein lineares Einfügen von Schlüsseln. Auf der y-Achse des Diagramms ist die ermittelte Leistung in Millionen Operationen pro Sekunde angegeben; die Anzahl der Schlüssel ist auf der x-Achse aufgetragen. Die Rot-Schwarz Bäume des STL-Sets werden am schnellsten aufgebaut. Das TreeSet von Java zeigt Leistungseinbrüche, während alle δ-B-Bäume stabile Performance bieten. Der B-Baum führt keine Längenkodierung durch und erreicht daher höhere Werte als die übrigen δ-B-Baum Varianten. 6 B-Tree Präfix-Summen δB-Tree Längenkodierter δB-Tree C++ STL Set Java TreeSet RB δB-Tree Millionen Operationen pro Sekunde 5 4 3 2 1 0 0 Mio. 2 Mio. 4 Mio. 6 Mio. Schlüssel 8 Mio. 10 Mio. 12 Mio. Abbildung 4.5: Einfügen von aufeinanderfolgenden Schlüsseln. 4.4.2 Randomisierte Schlüsselmengen Abbildung 4.6 zeigt das Einfügen einer zufällig durchmischten Menge von ursprünglich direkt aufeinanderfolgenden Schlüsseln. Die Ursprungsmen28 KAPITEL 4. EVALUIERUNG ge startet ab einem Offset von 4 Milliarden. Der zusätzliche Aufwand für die Längenkodierung schlägt sich bei Red-Black-, Längenkodierten- und Präfix-Summen δ-B-Bäumen in niedrigeren Leistungswerten nieder. 5 Millionen Operationen pro Sekunde 4.5 B-Tree Präfix-Summen δB-Tree Längenkodierter δB-Tree C++ STL Set Java TreeSet RB δB-Tree 4 3.5 3 2.5 2 1.5 1 0.5 0 0 Mio. 2 Mio. 4 Mio. 6 Mio. Schlüssel 8 Mio. 10 Mio. 12 Mio. Abbildung 4.6: Zufälliges Einfügen von aufeinanderfolgenden Schlüsseln. 5 Millionen Operationen pro Sekunde 4.5 B-Tree Präfix-Summen δB-Tree Längenkodierter δB-Tree C++ STL Set Java TreeSet RB δB-Tree 4 3.5 3 2.5 2 1.5 1 0.5 0 0 Mio. 2 Mio. 4 Mio. 6 Mio. Schlüssel 8 Mio. 10 Mio. 12 Mio. Abbildung 4.7: Zufälliges Einfügen von „nahe“ gelegenen Schlüsseln. Abbildung 4.7 zeigt den Einfügevorgang einer zufällig durchmischten Menge von vormals „nahe“ aufeinanderfolgenden Schlüsseln; jeder Schlüs29 KAPITEL 4. EVALUIERUNG sel der sortierten Ursprungsmenge besitzt einen zufälligen Abstand von maximal 1000 zu seinem Vorgänger. Nach Erstellung wird die Menge durchmischt. Die Leistungswerte des TreeSets und des B-Baumes sind bei steigenden Schlüsselzahlen annähernd identisch. 4.4.3 Rein zufällige Schlüsselmenge Die Abbildung 4.8 zeigt das Einfügen von zufälligen Schlüsseln aus dem Intervall [0; 263 − 2]. Bei zunehmender Schlüsselanzahl übertrifft der BBaum jede andere Struktur. 5 Millionen Operationen pro Sekunde 4.5 B-Tree Präfix-Summen δB-Tree Längenkodierter δB-Tree C++ STL Set Java TreeSet RB δB-Tree 4 3.5 3 2.5 2 1.5 1 0.5 0 0 Mio. 2 Mio. 4 Mio. 6 Mio. Schlüssel 8 Mio. 10 Mio. 12 Mio. Abbildung 4.8: Einfügen von zufälligen Schlüsseln. 4.5 Benchmarks über Suchvorgänge Der Suchvorgang wurde mit denselben Schlüsselmengen durchgeführt. Werden Schlüssel direkt aufeinanderfolgend gesucht, bietet das TreeSet die beste Leistung (Abb. 4.9). Im Falle des wahlfreien Suchens ist der BBaum am performantesten (Abb. 4.10 bis 4.12). Die Leistung des TreeSets und des Längenkodierten δ-B-Baumes nähern sich bei steigender Schlüsselanzahl aneinander an. Das STL-Set verliert in diesen Fällen stetig an Effizienz. 30 KAPITEL 4. EVALUIERUNG 14 12 Millionen Operationen pro Sekunde B-Tree Präfix-Summen δB-Tree Längenkodierter δB-Tree C++ STL Set Java TreeSet RB δB-Tree 10 8 6 4 2 0 0 Mio. 2 Mio. 4 Mio. 6 Mio. Schlüssel 8 Mio. 10 Mio. 12 Mio. Abbildung 4.9: Suche nach direkt aufeinanderfolgenden Schlüsseln. 5.5 5 4.5 Millionen Operationen pro Sekunde B-Tree Präfix-Summen δB-Tree Längenkodierter δB-Tree C++ STL Set Java TreeSet RB δB-Tree 4 3.5 3 2.5 2 1.5 1 0 Mio. 2 Mio. 4 Mio. 6 Mio. Schlüssel 8 Mio. 10 Mio. 12 Mio. Abbildung 4.10: Wahlfreier Suchvorgang auf direkte Schlüsselmengen. 31 KAPITEL 4. EVALUIERUNG 5 Millionen Operationen pro Sekunde 4.5 B-Tree Präfix-Summen δB-Tree Längenkodierter δB-Tree C++ STL Set Java TreeSet RB δB-Tree 4 3.5 3 2.5 2 1.5 1 0.5 0 Mio. 2 Mio. 4 Mio. 6 Mio. Schlüssel 8 Mio. 10 Mio. 12 Mio. Abbildung 4.11: Wahlfreier Suchvorgang auf „nahe“ gelegene Schlüssel. 5.5 5 4.5 Millionen Operationen pro Sekunde B-Tree Präfix-Summen δB-Tree Längenkodierter δB-Tree C++ STL Set Java TreeSet RB δB-Tree 4 3.5 3 2.5 2 1.5 1 0.5 0 Mio. 2 Mio. 4 Mio. 6 Mio. Schlüssel 8 Mio. Abbildung 4.12: Zufällige Suche. 32 10 Mio. 12 Mio. KAPITEL 4. EVALUIERUNG 4.6 Zusammenfassung der Ergebnisse Im Vergleich zum klassischen B-Baum kann bei den hier verwendeten Benchmarks der Speicherbedarf für Daten bei Delta-kodierten B-Bäumen um bis zu 2/3 geringer sein. Ein Performancevorteil ergibt sich daraus allerdings trotz des dadurch erhöhten Verzweigungsgrades nicht. Grund dafür ist die in der Implementierung eingesetzte Schichtenarchitektur. Der Längenkodierte δ-B-Baum bietet den größten Vorteil und erreicht bei wahlfreien Suchvorgängen annähernd die Leistungsfähigkeit des TreeSets von Java. 33 Kapitel 5 Zusammenfassung und Ausblick In dieser Arbeit wurden die Grundlagen für Delta-kodierte B-Bäume dargelegt und verschiedene Implementierungen hinsichtlich ihrer Geschwindigkeit mit dem Java TreeSet und STL-Set evaluiert. Verschiedene BBaum Typen werden durch unterschiedliche Knotenarten realisiert und besitzen ein abweichendes Verhalten in Hinblick auf Leistung und Speicherverbrauch. So übersteigt die Performance des klassischen B-Baumes in fast allen Anwendungsfällen die des TreeSets. Im Vergleich zu den δ-kodierten BBäumen unterstützt dieser aber keine Schlüsselkompression. Sein Speicherverbrauch bleibt dennoch geringer als der des TreeSets. Diese Leistungswerte werden von δ-B-Bäumen, welche durch eine Längenkodierung eine Reduktion der Daten ermöglichen, nicht erreicht. Die Gründe dafür sind die komplexeren Algorithmen und ein zusätzlicher Overhead, verursacht durch die Schichtenarchitektur der Implementierung. Der Vorteil dieser Bäume liegt im nochmals reduzierten Speicherverbrauch. Speziell für den Fall wenn Schlüssel bezüglich ihrer Werte nahe beieinander liegen, kann durch die Delta-Kodierung ein hoher Verzweigungsgrad im Baum erreicht werden, was eine bessere Suchleistung zur Folge hat. Als Ausblick ließe sich die Performance bei ausschließlicher Verwendung eines einzelnen Knotentyps durch das Entfallen der Schichtenarchitektur steigern. Bei Verwendung mehrere CPU-Kerne für den Suchvorgang, könnte die Gesamtleistung ebenso gesteigert werden. Die Implementierung, auf die diese Arbeit aufbaut, unterstützt derzeit lediglich einen Kern. Darüber hinaus könnte die Leistung der Präfix-Summen δB-Bäume verbessert werden, falls in der zukünftigen Version 9 von Java eine GPU-Beschleunigung konkretisiert wird. Ihre innere Struktur ist für 35 KAPITEL 5. ZUSAMMENFASSUNG UND AUSBLICK SIMD-Parallelismus optimal ausgelegt. Zusätzlich lassen sich Kopiervorgänge in den Speicher der GPU/APU durch den reduzierten Platzbedarf schneller durchführen. 36 Appendix A.1 Compilieren des HotSpot-Disassemblers Für das Compilieren des Disassembler-Moduls der OpenJDK unter Linux müssen folgende beiden Quellcode Archive geladen werden: • Java-Quellcode von http://download.java.net/openjdk/jdk7/ • GNU-Binutils von http://ftp.gnu.org/gnu/binutils/ Die Version der GNU-Binutils muss mit der vom System verwendeten übereinstimmen. Beide Archive werden in ein beliebiges Verzeichnis entpackt. Im Folgenden werden die Versionsnummer 1.7.0_50 für die Java und 2.23.1 für die Binutils Quellen angenommen. Die sich in /openjdk/hotspot/src/share/tools/hsdis/ befindliche Datei hsdis.c muss folgendermaßen editiert werden: • Unmittelbar nach dem Einbinden der eigene Headerdatei durch #i n c l u d e " h s d i s . h" muss #i n c l u d e <c o n f i g . h> eingefügt werden (noch vor allen anderen System-Headerdateien) • Die Zeile #i n c l u d e <s y s d e p . h> muss durch folgende beiden includes ersetzt werden: #i n c l u d e <s t r i n g . h> #i n c l u d e <e r r n o . h> 37 APPENDIX A.2. LÖSCHEN NACH CORMEN ET AL. Der Kopf der Datei sollte wie folgt aussehen: #include "hsdis.h" #include <config.h> #include <string.h> #include <errno.h> #include <libiberty.h> ... Anschließend wird in das /openjdk/hotspot/src/share/tools/hsdis/ Verzeichnis gewechselt und dort make ausgeführt. $ make BINUTILS=/home/user/tmp/binutils-2.23.2 Dieser Befehl definiert zusätzlich den Ort der Binutils. Nach erfolgter Kompilierung, existiert ein Verzeichnis mit Namen build. Dort befindet sich der Architektur entsprechend ein Unterverzeichnis mit der .so Datei (z.B. build/linux-amd64/hsdis-amd64.so). Abschließend wird diese nach $JAVA_HOME/jre/lib/amd64/server kopiert. Die folgende Zeile führt die Datei bench.jar aus und zeigt für die Klasse BenchmarkAllInOne und die Methode nodeSearch den AssemblerOutput: $ java -server -XX:+UnlockDiagnosticVMOptions \ -XX:PrintAssemblyOptions=intel \ ’-XX:CompileCommand=print,*BenchmarkAllInOne.nodeSearch’ \ -jar bench.jar -XX:PrintAssemblyOption=intel sorgt für die Ausgabe als Intel-Assembler anstatt der voreingestellten AT&T Syntax. A.2 Löschen nach Cormen et al. Dieser Algorithmus von Cormen et al. [LRSC01] minimiert die notwendigen Traversierungen im Baum. Beim Vorgehen nach Bayer [BM70] ist ein rekursiver Aufstieg bis maximal in die Wurzel durch die Unterlaufbehandlung sowohl beim Löschen aus Blättern als auch aus inneren Knoten möglich. 38 APPENDIX A.3. DER DELTA-KODIERTE B+ BAUM Analog zu diesem Algorithmus ersetzt das Entfernen eines Schlüssels aus einem inneren Knoten diesen mit dem direkten Vorgänger oder Nachfolger. Zur Unterscheidung wird sichergestellt, dass der Wurzelknoten des linken (respektive rechten) Subbaumes in dem sich der Vorgänger (Nachfolger) befindet, mindestens über die Hälfte voll ist. Ist im weiteren Ablauf eine Unterlaufbehandlung im Subbaum notwendig, kann sie sich höchstens bis zur Subwurzel ausbreiten. Die Einhaltung dieser Bedingung wird durch Ausgleichsrotationen aus den Geschwisterknoten erreicht. Ist dies nicht möglich, kann im Zuge der Verschmelzung beider Kindknoten der Schlüssel direkt gelöscht werden, ohne eine Ersetzung vorzunehmen. Befindet sich der zu entfernende Schlüssel in einem Blatt wird von der Wurzel aus abwärts bis dorthin die eben beschriebene Reparatur durchgeführt. Im Blatt selbst kann der Schlüssel ohne weitere Behandlung entfernt werden. Laut Cormen et al. ist meist eine einzelne Traversierung erforderlich, da sich die meisten Schlüssel in den Blättern befinden. Ein zweiter Traversierungslauf in Richtung der Wurzel kann beim Löschen aus einem inneren Knoten notwendig werden. A.3 Der Delta-kodierte B+ Baum Ein B+ Baum speichert alle Schlüssel in den Blättern; innere Knoten dienen ausschließlich als Suchindex. Der Index kann ähnlich wie beim Delta-kodierten B-Baum abgelegt werden. Es ist möglich die Schlüssel unkodiert in den Blättern zu speichern. Falls die Blätter als doppelt verkettete Liste realisiert sind, können Bereichsabfragen mit dieser Voraussetzung ohne Verwendung des Index durchgeführt werden. Werden die Schlüssel kodiert abgelegt, ist es erforderlich den Index bis zum Blattknoten zu durchlaufen, um das Offset zu berechnen und die Schlüssel rekonstruieren zu können. Die nächsten Abschnitte setzen kodierte Schlüssel in den Blättern voraus. Die unkodierte Variante ist dabei impliziert. A.3.1 Einfügen von Schlüsseln Neue Schlüssel werden in den Blättern eingefügt. Im Falle eines Aufspaltens muss ein modifizierter Split durchgeführt werden: Die zweite Hälfte des überlaufenden Knotens wird in einen neuen Knoten kopiert. Der neue Schlüssel wird nach seinem Betrag in diese Blätter eingeordnet. Da bei einem B+ Baum Separatoren des Index lediglich als Wegweiser dienen, kann das arithmetische Mittel bestehend aus dem größten Schlüssel 39 APPENDIX A.3. DER DELTA-KODIERTE B+ BAUM des linken Knotens und dem kleinsten Schlüssel des rechten Knotens im Elternknoten eingebunden werden. Abschließend wird der rechte Blattknoten bezüglich dieses lokalen Offsets neu kodiert. Im Inneren des Index werden Split und Einfügerotationen genau wie in Abschnitt 3.1.3 und 3.1.4 für innere Knoten ausgeführt. Eine Rotation über Blätter entspricht dem dort vorgestellten Algorithmus, mit dem Unterschied der Separatorberechnung über das arithmetische Mittel. A.3.2 Löschen von Schlüsseln Bei einem B+ Baum müssen Schlüssel ausschließlich aus den Blättern entfernt werden. Im Falle eines Unterlaufs kann, anders als beim in Abschnitt 3.1.5 beschriebenen Merge-Algorithmus, der Separator aus dem Elternknoten direkt entfernt werden (er wird nicht in das resultierende Blatt eingefügt). Eine Rotation in den Blättern wird analog zu dem in Abschnitt 3.1.4 vorgestellten Verfahren durchgeführt. Bei Entfernen von Werten aus dem Index wird eine Reparatur mit Hilfe von Rotationen oder Verschmelzung analog zum Delta-kodierten BBaum durchgeführt. Die große Rotation entfällt beim B+ Baum. 40 Literaturverzeichnis [ADHW99] A. Ailamaki, D. J. DeWitt, M. D. Hill and D. A. Wood: DBMSs on a Modern Processor: Where Does Time Go?, Proceedings of the 25th International Conference on Very Large Data Bases, VLDB ’99, Morgan Kaufmann Publishers Inc., San Francisco, CA, USA, pages 266–277, URL http://doi.acm.org/10.1145/645925.671662. [BHF09] C. Binnig, S. Hildenbrand and F. Färber: Dictionary-based Order-preserving String Compression for Main Memory Column Stores, Proceedings of the 2009 ACM SIGMOD International Conference on Management of data, SIGMOD ’09, ACM, New York, NY, USA, pages 283–296, URL http: //doi.acm.org/10.1145/1559845.1559877. [BM70] R. Bayer and E. M. McCreight: Organization and Maintenance of Large Ordered Indices, Proceedings of the 1970 ACM SIGFIDET (now SIGMOD) Workshop on Data Description, Access and Control, SIGFIDET ’70, ACM, New York, NY, USA, pages 107–141, URL http://doi.acm.org/ 10.1145/1734663.1734671. [BMR01] P. Bohannon, P. McIlroy and R. Rastogi: Main-Memory Index Structures with Fixed-Size Partial Keys, SIGMOD Rec., volume 30(2), (2001), pages 163–174, URL http://doi. acm.org/10.1145/376284.375681. [BU77] R. Bayer and K. Unterauer: Prefix B-trees, ACM Transactions on Database Systems (TODS), volume 2(1), (1977), pages 11–26. [CGM01] S. Chen, P. B. Gibbons and T. C. Mowry: Improving Index Performance through Prefetching, SIGMOD Rec., volume 30(2), (2001), pages 235–246, URL http://doi.acm. org/10.1145/376284.375688. 41 APPENDIX LITERATURVERZEICHNIS [Com79] D. Comer: Ubiquitous B-Tree, ACM Comput. Surv., volume 11(2), (1979), pages 121–137, URL http://doi.acm. org/10.1145/356770.356776. [HP03] R. A. Hankins and J. M. Patel: Effect of Node Size on the Performance of Cache-Conscious B+-Trees, ACM SIGMETRICS Performance Evaluation Review, volume 31(1), (2003), pages 283–294. [LC86] T. J. Lehman and M. J. Carey: A Study of Index Structures for Main Memory Database Management Systems, Proceedings of the 12th International Conference on Very Large Data Bases, VLDB ’86, Morgan Kaufmann Publishers Inc., San Francisco, CA, USA, pages 294–303. [LLSL10] I.-h. Lee, J.-w. Lee, J. Shim and S.-g. Lee: Cache Conscious Trees on Modern Microprocessors, Proceedings of the 4th International Conference on Uniquitous Information Management and Communication, ICUIMC 2010, ACM, New York, NY, USA, pages 43:1–43:5, URL http://doi.acm.org/10. 1145/2108616.2108668. [LRSC01] C. E. Leiserson, R. L. Rivest, C. Stein and T. H. Cormen: Introduction to Algorithms, The MIT press, 2nd edition, 2001. [RR99] J. Rao and K. A. Ross: Cache Conscious Indexing for Decision-Support in Main Memory, Proceedings of the 25th International Conference on Very Large Data Bases, VLDB ’99, Morgan Kaufmann Publishers Inc., San Francisco, CA, USA, pages 78–89, URL http://doi.acm.org/10.1145/ 342009.335449. [RR00] J. Rao and K. A. Ross: Making B+- Trees Cache Conscious in Main Memory, SIGMOD Rec., volume 29(2), (2000), pages 475–486, URL http://doi.acm.org/10.1145/335191. 335449. 42