Fakultät Informatik Institut für Systemarchitektur, Professur für Datenbanken Belegarbeit PARALLELE GRAPHALGORITHMEN IN NEO4J Sascha Peukert Matr.-Nr.: 3680957 Betreut durch: Prof. Dr.-Ing. Wolfgang Lehner und: Dr.-Ing. Hannes Voigt Michael Hunger Eingereicht am 29. Februar 2016 2 ERKLÄRUNG Ich erkläre, dass ich die vorliegende Arbeit selbständig, unter Angabe aller Zitate und nur unter Verwendung der angegebenen Literatur und Hilfsmittel angefertigt habe. Dresden, 29. Februar 2016 3 4 ZUSAMMENFASSUNG Die Analyse von Graphen ist ein Kernanwendungsfall von Graphdatenbanken. Neo4j bietet in der neusten Version Unterstützung für eine effiziente Analyse großer Graphen wie sie z.B. in sozialen Netzwerken auftreten. Für die vorliegende Belegarbeit sind auf Basis der Neo4j Version 2.3.2 eine Reihe von gängigen Graphanalyse-Algorithmen implementiert worden. Dabei handelt es sich um Strongly Connected Components, Weakly Connected Components und Random Walk. Dafür wurde Neo4js Kernel API genutzt und gegenüber der weiter verbreiteten Core API evaluiert. Besonderes Augenmerk liegt auf der Parallelisierung der Algorithmen und dem Vergleich zu sequenziellen Implementierungen der Analyse-Algorithmen. 5 6 INHALTSVERZEICHNIS 1 2 Einleitung 11 1.1 Motivation und Zielstellung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 11 1.2 Gliederung der Arbeit . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 12 1.3 Vorstellung des Beispieldatensatzes . . . . . . . . . . . . . . . . . . . . . . . . . . . 13 Grundlagen 15 2.1 Konventionelle Datenbanksysteme . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15 2.2 Graphdatenbanken . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 19 2.3 Neo4j . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 21 2.3.1 Allgemeines . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 21 2.3.2 APIs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 21 2.3.3 Server Plugins und Unmanaged Extensions . . . . . . . . . . . . . . . . . . . 26 2.4 Graphanalyse . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 27 2.5 Parallelverarbeitung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 28 2.5.1 Ziele . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 28 2.5.2 Parallelität in der Hardware . . . . . . . . . . . . . . . . . . . . . . . . . . . . 28 2.5.3 Parallelität in der Software . . . . . . . . . . . . . . . . . . . . . . . . . . . . 29 2.5.4 Grenzen der Parallelisierung . . . . . . . . . . . . . . . . . . . . . . . . . . . 30 7 Inhaltsverzeichnis 3 Graphalgorithmen 33 3.1 Random Walk . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 33 3.1.1 Zielstellung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 33 3.1.2 Sequenzieller Algorithmus . . . . . . . . . . . . . . . . . . . . . . . . . . . . 34 3.1.3 Paralleler Algorithmus . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 35 Weakly Connected Components . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 36 3.2.1 Zielstellung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 36 3.2.2 Sequenzieller Algorithmus . . . . . . . . . . . . . . . . . . . . . . . . . . . . 36 3.2.3 Paralleler Algorithmus . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 37 Strongly Connected Components . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 39 3.3.1 Zielstellung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 39 3.3.2 Sequenzieller Algorithmus . . . . . . . . . . . . . . . . . . . . . . . . . . . . 39 3.3.3 Paralleler Algorithmus . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 42 3.2 3.3 4 5 8 Projektaufbau und Implementierung 47 4.1 Projektaufbau . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 47 4.2 Implementierung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 49 Messungen 53 5.1 Testbedingungen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 53 5.2 Vergleich der APIs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 56 5.3 Random Walk . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 58 5.3.1 Auswertung Speedup . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 58 5.3.2 Auswertung Ressourcenauslastung . . . . . . . . . . . . . . . . . . . . . . . 60 5.4 Experimentelle Ermittelung des Parameters BATCHSIZE . . . . . . . . . . . . . . . 63 5.5 Weakly Connected Components . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 65 5.5.1 Auswertung Speedup . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 65 5.5.2 Auswertung Ressourcenauslastung . . . . . . . . . . . . . . . . . . . . . . . 67 Inhaltsverzeichnis 5.6 6 Strongly Connected Components . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 69 5.6.1 Auswertung Speedup . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 69 5.6.2 Auswertung Ressourcenauslastung . . . . . . . . . . . . . . . . . . . . . . . 71 Zusammenfassung und Ausblick 75 6.1 Zusammenfassung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 75 6.2 Ausblick . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 76 Literaturverzeichnis 76 9 Inhaltsverzeichnis 10 1 1.1 EINLEITUNG MOTIVATION UND ZIELSTELLUNG In der heutigen Zeit, in der Daten als "das Öl des 21. Jahrhunderts"1 bezeichnet werden, erzeugen wir täglich Millionen von persönlichen Datensätzen. Mit unserer Hilfe werden sie aufgenommen, ausgewertet, zueinander in Verbindung gesetzt und daraus Muster extrahiert. Allerdings lassen sich längst nicht alle individuellen Daten mit den konventionellen Systemen in ein fest strukturiertes Schema pressen. An diesem Punkt setzen Graphdatenbanksysteme wie Neo4j an. Sie stellen die Verbindungen zwischen individuellen Daten in den Vordergrund, statt Daten isoliert von einander zu betrachten. Viele Prozesse und komplexe Systeme lassen sich sehr angenehm und einfach als Graph (also durch Knoten und Kanten) darstellen und können wiederum von Graphdatenbanksystemen nicht nur gespeichert und abgefragt, sondern auch ausgewertet werden. Allerdings ist der bisherige Stand der Technik, dass Auswertungen und komplexe Analysen nicht von der Datenbank selbst, sondern eher von externen Graphanalyse-Systemen wie Giraph oder Sparks GraphX durchgeführt werden. Die Graphdatenbank wird in solchen Szenarien nur als Zugriffsmittel auf die physischen Daten genutzt. Laufzeiten werden beispielsweise von Netzwerklatenzen unnötig verlängert. Dabei könnte man einiges an Potential ausnutzen und Latenzen einsparen, wenn man die Daten direkt in der Datenbank analysieren würde. In dieser Belegarbeit wird anhand von Graphanalysealgorithmen gezeigt, welche Möglichkeiten in Neo4j stecken, um im System selbst Analysen durchzuführen, während parallel der normale OLTP2 -Workload - beliebige Transaktionen, die Daten erstellen, updaten und abrufen - ungestört weiterläuft. Die Analyseergebnisse werden dabei als Attribute zurück an die Knoten des Graphen geschrieben und reichern so den Graph um neue Informationen an. Weiterhin ist es Ziel ein Konzept zu entwickeln, um Analyseprozeduren dem Neo4j-Server als Erweiterung des Funktionsumfangs zur Verfügung stellen zu können. Um ein Maximum an Leistungsfähigkeit auszunutzen, wird dazu Neo4js schnellste und performanteste Java-Programmierschnitstelle ermittelt und ge1 Stefan Groß-Selbeck, CEO von Xing in [1] 2 Online-Transaction-Processing 11 Kapitel 1 Einleitung nutzt. Der weitere Fokus dieser Arbeit liegt auf der Parallelisierung der Analysealgorithmen, um so Laufzeiten einzusparen und gleichzeitig eine profitablere Auslastung der verwendeten Hardware zu erhalten. 1.2 GLIEDERUNG DER ARBEIT Im Kapitel 2 werden die technischen und theoretischen Grundlagen für das Projekt erläutert. Datenbanken, Graphdatenbanken und Neo4j im Speziellen werden hier vorgestellt. Abschließend wird eine Einführung in die Graphanalyse und deren Anwendungsgebiete gegeben. Das Konzept der Parallelverarbeitung wird ebenfalls beschrieben. Das Kapitel 3 stellt die im Projekt implementierten Graphalgorithmen Random Walk, Weakly Connected Components und Strongly Connected Components vor. Dabei wird jeweils zuerst das Problem erläutert und danach ein sequenzieller und ein paralleler Algorithmus zur Lösung des Problems vorgestellt. Der Aufbau und die Implementierung des Projekts sind der Fokus von Kapitel 4. Hier wird eine schematische Übersicht der Implementierung mit zusätzlichen Details gegeben, um die Ergebnisse der Arbeit besser verstehen und reproduzieren zu können. Kapitel 5 beschäftigt sich mit den Messergebnissen der implementierten Algorithmen auf den verschiedenen genutzten Testsystemen. Diese und die weiteren Testbedingungen werden zu Beginn des Kapitels kurz vorgestellt. Danach erfolgt eine genaue Auswertung der Messergebnisse. In Kapitel 6 findet eine Zusammenfassung der Ergebnisse der Arbeit statt. Außerdem wird ein Ausblick über die Ideen, Anwendungen und Konzepte gegeben, die sich aufgrund der Erkenntnisse aus diesem Projekts entwickeln könnten. 12 1.3 Vorstellung des Beispieldatensatzes 1.3 VORSTELLUNG DES BEISPIELDATENSATZES Zur Veranschaulichung der Algorithmen wird in den folgenden Kapiteln der Graph aus der Abbildung 1.1 genutzt. Es handelt sich um einen beispielhaften Ausschnitt eines Graphen, der anhand der deutschen Wikipedia mit Graphipedia (siehe Kapitel 5.1) erzeugt wurde. Die Knoten entsprechen hierbei Seiten. Die Verbindungen zwischen diesen Knoten stellen die Links zwischen den Seiten dar. Abbildung 1.1: Beispielhafter Ausschnitt des durch Graphipedia generierten Graphen der deutschen Wikipedia 13 Kapitel 1 Einleitung 14 2 GRUNDLAGEN Das folgende Kapitel soll einen Überblick über die Grundlagen und Techniken geben, auf denen diese Arbeit beruht. Ausgehend von einem kurzen Überblick über konventionelle, SQL-basierte Datenbanksysteme, werden Graphdatenbanken allgemein und Neo4j als prominenter Vertreter im Speziellen vorgestellt. Weiterhin werden Konzepte und mögliche Anwendungen von Graphanalyse erläutert und eine kurze Einführung in die Parallelverarbeitung gegeben. 2.1 KONVENTIONELLE DATENBANKSYSTEME Allgemeines Relationale Datenbanksysteme sind die seit den 80er Jahren vorherrschenden Systeme zum Speichern und Verwalten von Datenmengen. Die Interaktion mit diesen Systemen läuft über Transaktionen in der standardisierten Sprache SQL (Structured Query Language) oder, für Systeme die sich vom Standard etwas entfernen, in Dialekten von SQL. Der allgemeine Arbeitsablauf mit relationalen Datenbanksystemen (RDBS) beginnt mit dem Definieren von Schemata. Ein Schema beschreibt den Aufbau von Tabellen mit seinen Spalten und deren genutzten Datentypen. Zusätzlich charakterisiert es die Verbindungen zwischen den Spalten verschiedener Tabellen. Alle Daten, die das relationale Datenbanksystem (RDBS) verwendet, müssen als Datensatz in Tabellen geschrieben werden. Entsprechend richten sich die Schemata der Tabelle meist nach den Strukturen der zu speichernden Daten. Die Spalten einer Tabelle entsprechen dabei den Eigenschaften/Attributen eines Datensatzes. Beispiel: Eine Tabelle mit einer Spalte "Name", welche Zeichenketten zulässt, wird also Datensätze beherbergen, die jeweils einen Namen besitzen (können). Jeder Datensatz besitzt ein einzigartiges Attribut, seinen Primärschlüssel, welcher auf bestimmten Spalten definiert ist. Zusammengenommen identifizieren diese Spalten des Primärschlüssels den Datensatz eindeutig. Zusätzlich können auf Spalten auch Fremdschlüsselattribute definiert 15 Kapitel 2 Grundlagen werden, welche die Verbindungen zu Spalten anderer Tabellen herstellen können. Diese Verbindungen können ein spezielles Verhalten z.B. für den Fall des Löschens eines Datensatzes besitzen. Die Abbildung 2.1 zeigt zwei SQL-Anweisungen um den Graphen aus Abbildung 1.1 als Tabellen anzulegen. 1 2 3 4 5 6 -- Query 1 CREATE TABLE SITES ( SiteId INTEGER , Name VARCHAR (255) , PRIMARY KEY ( SiteId ) ); 7 8 9 10 11 12 13 14 15 16 17 -- Query 2 CREATE TABLE LINK ( SiteA INTEGER , SiteB INTEGER , PRIMARY KEY ( SiteA , SiteB ) , FOREIGN KEY ( SiteA ) REFERENCES SITES ( SiteId ) , FOREIGN KEY ( SiteB ) REFERENCES SITES ( SiteId ) ); Abbildung 2.1: SQL-Anweisungen, um Tabellen anzulegen, die den Graphen aus Abbildung 1.1 im relationalen System darstellen können Nachdem die Tabellen angelegt worden sind, können noch Indexe [2], Trigger [3] oder Constrains [4] hinzugefügt werden, um die anwendungsspezifischen Anforderungen an das Datenbanksystem abzudecken. Beispielsweise könnte man einen Trigger hinzufügen, der vor dem Einfügen eines Datensatzes testet, ob bestimmte Eigenschaften erfüllt worden sind. Ist diese erste Phase der Erstellung eines Schemas abgeschlossen, kann die Datenbank produktiv genutzt werden. Clients können, je nach Zugriffsrecht, lesende oder schreibende Transaktionen durchführen und somit den Datensatzbestand verändern. Veränderungen am Schema können dennoch weiterhin durchgeführt werden. Die Abbildung 2.2 zeigt die Tabellen SITES und LINKS im Zustand, in dem der komplette Beispielgraph abgebildet und abgespeichert wurde. Die Abbildung 2.3 zeigt drei beispielhafte Querys für das Anlegen (Query 1), das Verändern (Query 2) und das Abfragen von Datensätzen (Query 3). Diese letzte Query lässt den Benutzer herausfinden, ob es Links zwischen den Seiten von "Silvester McCoy" und "Doctor Who" existieren. Dazu sind bereits zwei JOIN notwendig, um die miteinander korrespondierenden Datensätze zu verknüpfen. 16 2.1 Konventionelle Datenbanksysteme SiteId 0 1 2 3 4 5 6 7 8 9 10 11 12 13 Tabelle SITES Name Doctor Who Matt Smith (Schauspieler) Silvester McCoy The KLF Prototyp (Technik) Gerhard Acktun Chameleon Circuit Bohemian Rhapsody Fan Alexander Allerson Einzelbild Daily Telegraph Matt Smith Matthew Smith Tabelle LINKS SiteA SiteB 0 1 0 2 0 3 0 4 0 5 0 6 0 7 0 8 0 9 0 10 0 11 1 0 2 0 6 0 12 1 12 13 13 12 Abbildung 2.2: Links: Tabelle SITES, die Seiten verwaltet Rechts: Tabelle LINKS, die Verlinkungen von Seite A auf Seite B mit SiteIds verwaltet 1 2 3 -- Query 1 INSERT INTO SITES ( SiteId , Name ) VALUES (8 , ’ Fan ’ ); 4 5 6 7 8 -- Query 2 UPDATE SITES SET Name = ’ Prototyp ( Technik ) ’ WHERE SiteId = ’4 ’; 9 10 11 12 13 14 15 16 17 -- Query 3 SELECT * FROM SITES as S1 JOIN LINKS as L ON S1 . SiteId = L . SiteA JOIN SITES as S2 ON L . SiteB = S2 . SiteId WHERE S1 . Name = ’ Silvester McCoy ’ AND S1 . Name = ’ Doctor Who ’; Abbildung 2.3: Beispielhafte SQL-Anweisungen zum Anlegen (Query 1), Verändern (Query 2) und Abfragen von Datensätzen (Query 3) Vorteile Wie bereits erwähnt, sind relationale Datenbanksysteme und somit auch SQL der vorherrschende und verbreitete Industriestandard. Obwohl sich die unterschiedlichen Systeme in einzelnen Teilen vom Standard entfernen, fällt es den meisten Entwicklern recht einfach zwischen den verschiedenen Systemen zu wechseln. Das dürfte ein attraktives Merkmal für die Industrie sein. RDBS sind daher einer der Kernpunkte der Forschung im Datenbankbereich. Sie erzielen gute Leistungen im Verwalten von großen Datenmengen, welche eine festes Struktur aufweisen. 17 Kapitel 2 Grundlagen Probleme Eines der größten Probleme mit relationalen Datenbanksystemen ist das kostenintensive und häufige Verknüpfen von Datensätzen für Abfragen. Sobald zwischen Datensätzen verschiedener Tabellen (oder einer Tabelle mit sich selbst) eine Verbindung hergestellt werden soll, muss zur Laufzeit eine JOIN-Operation durchgeführt werden. Nach [5] sind JOIN aber besonders rechenund ressourcenintensive Operationen, welche exponentielle Kosten haben. Das heißt, dass je größer die Datenmenge ist, welche eine Tabelle verwaltet, desto anspruchsvoller ist es ein JOINProdukt zu bilden. Oftmals muss sogar der worst-case, ein Nested Loop Join [6], zwingend gebildet werden. Das entspricht einem Aufwand von O(| Tabelle A| ∗ | Tabelle B|]), obwohl man sich vielleicht nur für einzelne Datensätze aus dem Ergebnis dieser Operation interessiert. Diese JOIN-Komplexität nimmt sogar noch zu, wenn man bedenkt, dass man oftmals mehrere Verbindungen zwischen Tabellen benötigt. Hat man sogenannte many-to-many Verbindungen, so muss man JOIN-Tables einführen, die nur aus Fremdschlüsseln beider beteiligter Tabellen bestehen. Die Tabelle LINK aus Abbildung 2.2 ist so eine JOIN-Table. Wollen wir beispielsweise prüfen, ob ein gerichteten Pfad zwischen "Matthew Smith" und "Silvester McCoy" besteht, so ergibt sich folgendes Problem: Dem Benutzer ist nicht bekannt wie viele Verbindungen zwischen den beiden Knoten bestehen. Prüfen wir ob beide direkt miteinander verbunden sind (analog zu Query 3 aus Abbildung 2.3) würde man drei JOIN benötigen. Um herauszufinden, ob wir einen Pfad gefunden haben, wenn wir einem weiteren Link folgen, benötigt die Query zwei zusätzliche JOIN. Somit brauchen wir um den Beispielpfad Matthew Smith → Matt Smith → Matt Smith(Schauspieler ) → Doctor Who → Silvester McCoy zu finden und zu testen, eine Query mit insgesamt neun JOIN-Operationen. Da wir nun aber als Benutzer nicht wissen können, ob ein Pfad existiert und über wie viele Zwischenknoten er führt, können wir in der Anfrage gar nicht wissen, wie viele JOIN wir spezifizieren müssen. Zusätzlich zu dem Fakt, dass JOIN für das Datenbanksystem eine teure Operation sind, können RDBS also solche Probleme nur schlecht beantworten. Das liegt vor allem daran, dass sich Daten mit fehlender, globaler Struktur nur schwerlich im relationalen System abbilden lassen. Zwar sind Veränderungen sowie Erweiterungen von Tabellen, sowie das Aufspalten von Tabellen am Schema einer produktiv genutzten, relationalen Datenbank möglich, aber stets mit einiger Arbeit verbunden. Dabei müssen oftmals Daten von einer Version in die nächste migriert werden, was mit einigem Aufwand verbunden sein kann. 18 2.2 Graphdatenbanken 2.2 GRAPHDATENBANKEN Allgemeines Graphdatenbanken sind eine Unterkategorie der NOSQL-Datenbanken. NOSQL steht in hier für "Not only SQL". Diese Systeme nutzen keine relationale Algebra als Basis für Anfragebearbeitung und somit auch kein standardisiertes SQL als Anfragesprache. Stattdessen setzen sie auf andere Datenmodelle. So gibt es nach [7] unter anderem dokumentenorientierte Datenbanken wie CouchDB, Key-Value Stores wie Redis und eben Graphdatenbanken wie Neo4j. Auf letztgenannten soll hier der Fokus gelegt werden. Wie in [8] beschrieben, benutzen Graphdatenbanken das Labeled Property Graph Model. Es besteht aus Knoten (Nodes), die selbst eine beliebige Menge von Key-Value Paaren als Attribute (Properties) verwalten können, und aus Relationships, welche als gerichteten Kanten zwischen Knoten eine gegebene Semantik innewohnt. Wie auch Knoten können Relationships Attribute besitzen. Dabei folgen Knoten und Kanten des Graphen keinem globalen Schema, sondern können beliebig erstellt und jederzeit problemlos erweitert werden. Knoten können, je nach System, außerdem über ein oder mehrere Label verfügen. Diese werden dem System vom Benutzer als Gruppierungsmerkmal zur Verfügung gestellt und sind daher eine zusätzliche Möglichkeit, Knoten selbst mit Semantik auszustatten, die später unter anderem als Filterkriterium genutzt werden kann. Beispiel: "Gib mir alle Knoten, die mit ’Person’ gelabelt sind". Verbreitete Graphdatenbanksysteme sind beispielsweise OrientDB [9] und Neo4j [10]. Vorteile Im Gegensatz zu relationalen Datenbanksystemen gehen Graphdatenbanken durch ihr Datenmodel sehr viel effizienter mit Verbindungen zwischen Datensätzen um. Während also RDBS mit der JOIN-Problematik zu kämpfen haben, können Graphdatenbanken Verbindungen zwischen Knoten meist in O(1) (siehe [11]) verarbeiten. Da es keine global vorgegebenen Schemata für die Daten gibt, sind jederzeit (also auch während das System produktiv genutzt wird) beliebige Änderungen oder Erweiterungen möglich. Dieser Fakt macht die Systeme sehr flexibel und zu guten Kandidaten, um komplexe Gebilde wie z.B. soziale Netzwerke, welche sich ständig verändern, abzubilden. Letztlich sind Graphdatenbanken nach [8] näher an der intuitiver Modellierung von Daten orientiert als relationale Systeme. Komplexe Objekte müssen nicht erst manuell auf normalisierte Tabellen abgebildet werden, sondern können als Objekt mit Referenzen (Verbindungen) auf andere Objekte direkt abgelegt werden. 19 Kapitel 2 Grundlagen Probleme Dass Graphdatenbanken kein globales Datenschema haben, hat allerdings nicht nur Vorteile. Es führt mindestens dazu, dass sich Benutzer, ohne es zu testen, nie sicher sein können, ob bestimmte Gesetzmäßigkeiten gelten. Zum Beispiel weiß man nicht, ob alle Knoten einer Menge bestimmte Properties besitzen. Diese Metaebene muss der Benutzer bei der Modellierung der Daten außerhalb der Datenbank spezifizieren. Aber eines der größten Probleme für Graphdatenbanken ist nach [13] das Skalieren des Graphen. Wächst ein Graph auf eine Größe heran, an dem er über mehrere Maschinen verteilt werden muss (horizontal scale out), folgt daraus ein NP hartes Problem1 , welches nicht einfach zu lösen ist. Dieses Problem ist daher aktiver Forschungsschwerpunkt in diesem Bereich. 1 d.h. 20 es gibt eine nichtdeterministische Turingmaschine, die das Problem in Polynomialzeit lösen kann 2.3 Neo4j 2.3 NEO4J 2.3.1 Allgemeines Neo4j ist die zur Zeit populärste Vertreterin aller Graphdatenbanken [14]. Sie ist in Java und Scala implementiert und ist ein Open-Source Projekt, deren Quellcode auf GitHub [15] verfügbar ist. Es existiert eine GPLv3 lizenzierte Communitiy Version und eine AGPLv3 lizenzierte Enterprise Version. Eine Zusammenfassung der Unterschiede der Versionen finden sich in [16]. Im Gegensatz zu anderen Graphdatenbanken und NOSQL-Systemen unterstützt Neo4j alle ACIDEigenschaften (Atomarität, Konsistenzerhaltung, Isolation , Dauerhaftigkeit), was es nach [17] zu einem besonders verlässlichen System macht. 2.3.2 APIs User code Traverser API Cypher Core API Kernel API Abbildung 2.4: Logische Sicht der APIs in Neo4j nach [12] Um mit Neo4j zu kommunizieren und mit dem Programmcode interagieren zu können, besitzt Neo4j einige Programmierschnittstellen (APIs). Eine logische Darstellung der APIs, die Neo4j zur Verfügung stellt, findet sich in Abbildung 2.4. Wie man sieht, bauen Cypher und die Core API grundlegend auf der Kernel API auf, die Traverser API sogar noch auf der Core API. Der Abbildung nach bieten sich für den Benutzer ausschließlich die oberen drei APIs an. In den nachfolgenden Abschnitten betrachten wir Cypher, die Core API und die grundlegende Kernel API näher. Da die Traverser API für das Projekt nicht benutzt wurde, soll sie an dieser Stelle nur erwähnt, aber nicht näher beleuchtet werden. 1 2 MATCH ( n : Page ) -[ m : LINK ] - >( x : Page ) RETURN n , m , x ; Abbildung 2.5: Beispielhafte Cypher Query zur Abfrage aller Page-Knoten mit Link-Kanten der Datenbank (Knoten ohne Kanten sind nicht Teil der Ergebnismenge) Cypher Cypher ist Neo4js eigene, stark an SQL angelehnte Anfragesprache. Um einen Überblick über Cypher als API zu ermöglichen, bietet es sich an, eine kurze Einführung in die Grundlagen von 21 Kapitel 2 Grundlagen Abfragen mit Cypher zu geben. Die MATCH-, WHERE- und RETURN-Klauseln sind die grundlegenden Bestandteile einer Cypher-Query und daher nachfolgend näher beschrieben. MATCH Die MATCH-Klausel spezifiziert das gesuchte Muster im Graphen. Knoten werden mit runden Klammern angegeben und Verbindungen als Pfeile wie z.B. –-> . Abbildung 2.5 beschreibt eine Query, die alle Knoten n mit Verbindung m zu anderen Knoten x zurückliefert. Alle beteiligten Knoten x und Verbindungen m werden ebenfalls zurückgeliefert. Knoten, die weder ein- noch ausgehende Kanten haben, gehören nicht zum Muster und entsprechend nicht zum Ergebnis. Mit der entsprechenden Datenbasis liefert diese Query den in Abbildung 1.1 dargestellten Beispielgraphen. Um Muster zu spezifizieren, die mit speziell gelabelten Knoten und Kanten agieren, kann das jeweilige Label angegeben werden. Die MATCH-Klausel (n:Page)-[m:LINK-]->(x:Page) entspricht der MATCH-Klausel aus Abbildung 2.5 mit der Einschränkung dass nur Knoten der spezifizierten Labels betrachtet werden. Es ist wichtig zu erwähnen, dass Cypher keine explizite Benennung von Knoten oder Kanten benötigt, wenn diese nicht zurückgeliefert werden sollen. Ein Muster wie z.B. ()-[:LINK]->(a:Page) ist also ebenfalls gültig. Weiterhin lassen sich Pfade wie ()–->()–->() unter Nutzung der *.. Semantik auf ()-[*..2]->() verkürzen. WHERE Analog zur WHERE-Klausel in SQL, kann die WHERE-Klausel in Cypher zum Filtern von Datensätzen genutzt werden. Beispielsweise kann man spezifizieren, dass Knoten weg gefiltert werden, welche bestimmte Eigenschaften nicht erfüllen. Im Falle von MATCH (n:Person) WHERE n.age > 17 RETURN n; interessieren wir uns z.B. nur für Personen, die mindestens 18 Jahre alt sind. Alle anderen Knoten kommen in der Ergebnismenge nicht vor. Die WHERE-Klausel ist, wie man in Abbildung 2.5 und 2.6 erkennen kann, optional. RETURN Die RETURN-Klausel gibt an, welche in der MATCH-Klausel spezifizierten Elemente (Knoten, Kanten, Werte der Attribute oder ganze Pfade) als Ergebnis zurückgeliefert werden sollen. Es ist sichergestellt, dass nur Daten, die dem gegebenen Muster entsprechen, zurückgeliefert werden. Zum Vergleich ist in Abbildung 2.6 eine Cypher-Query dargestellt, die alle Pfade der Länge 4 zwischen den Knoten "Matthew Smith" und "Silvester McCoy" findet. Diese Query ist das sehr viel kompaktere Cypher-Äquivalent zur in Abschnitt 2.1 diskutierten problematischen SQL-Query mit 9 JOIN-Anweisungen. 1 2 3 4 5 MATCH p = (: Page { title : " Matthew Smith " }) -[: LINK *4] - > (: Page { name : " Silvester McCoy " }) RETURN p ; Abbildung 2.6: Cypher Query die alle Pfade der Länge 4 zwischen den Knoten "Matthew Smith" und "Silvester McCoy" liefert 22 2.3 Neo4j Natürlich kann diese Arbeit nicht den vollen Umfang von Cypher darstellen. Somit handelt es sich bei den vorgestellten Klauseln und Features nur um die wesentlichen Grundzüge von Cypher. Weiterführende Informationen zu Cypher finden sich unter [18]. Core API Die Core API von Neo4j [19] bildet die Entitäten des im Abschnitt 2.2 beschriebenen Labeled Property Graph Model als Objekte in Java-Klassen ab. Um einen Eindruck über die Art der API zu bekommen, werden nachfolgend einige der wichtigsten Klassen mit dazugehörigen, ausgewählten Methoden und Attributen vorgestellt. Vollständige Informationen zu allen beteiligten Klassen sind unter den jeweiligen Seiten in [19] zu finden. PropertyContainer Das Interface PropertyContainer definiert nach [20] eine API um die Properties von Knoten und Kanten zu verwalten. Properties werden als Key-Value Paare abgelegt. Dabei muss der Key immer ein String sein und Values können von einen beliebigen Typen aus dem Spektrum der primitiven Datentypen von Java (int, byte, float ...), Strings oder Arrays von primitiven Datentypen sein. Diese Klasse stellt einige Methoden zum Zugriff auf diese Properties bereit wie z.B. hasProperty(String key), getProperty(String key), setProperty(String key, Object value), removeProperty(String key) und getAllProperties(String... keys). Die Namen der Methoden beschreiben vollständig ihre Funktion und Anwendung, sodass an dieser Stelle diese nicht extra benannt werden müssen. Node Das Interface Node [21] erbt von PropertyContainer. Es repräsentiert einen Knoten des Graphen und verwaltet alle Properties, Labels und Relationships zu anderen Knoten. Nodes besitzen eine für das System einzigartige Id. Sollte ein Knoten allerdings gelöscht werden, kann das System die Id wieder neu vergeben. Diese Klasse stellt, neben eigenen Verwaltungsmethoden wie delete() oder getId(), drei Gruppen von Methoden zur Verfügung: Zur ersten Gruppe gehören (oftmals mehrfach überladene) Operationen, die mit Relationships arbeiten. Beispielsweise Die Methode getDegree() gibt unabhängig von Richtung oder des Typs der Relationships, für einen Knoten an, wie viele Relationships mit diesem Knoten verbunden sind. Ein zweites Beispiel, getRelationships(Direction dir) liefert alle Relationships, die, je nach angegebener Richtung dir, entweder vom aktuellen Knoten ausgehen oder eingehen. Ein weiteres Beispiel wäre die Methode createRelationshipTo(Node otherNode, RelationshipType type) zum Erstellen einer Relationship vom aktuellen Knoten zu einem anderen. Die zweite Gruppe machen Operationen aus, welche mit Properties und Labels arbeiten (siehe auch PropertyContainer). Beispiele: addLabel(Label label) und hasLabel(Label label). 23 Kapitel 2 Grundlagen Abschließend gibt es Operationen, die sogenannte Traverser erstellen. Traverser gehören zur in Abbildung 2.4 dargestellten Traverser API, welche in dieser Arbeit nicht weiter betrachtet werden. Informationen dazu finden sich unter [22]. Label Dieses Interface repräsentiert nach [23] Labels, die auf Knoten definiert werden können. Es ist möglich, Knoten beliebig viele Labels zuzuweisen. Label werden ausschließlich über ihre Namen identifiziert, weshalb die Methode name() besonders wichtig ist. Das Handling mit Labels erfolgt primär über die Klasse Node. Relationship Das Interface Relationship [24] erbt von PropertyContainer und repräsentiert eine gerichtete Verbindung zwischen zwei Knoten im Graphen. Entsprechend besitzt eine Relationship einen Startknoten, einen Endknoten und einen RelationshipType. Die Richtung der Verbindung ist durch die Angabe von Start- und Endknoten implizit gegeben, kann aber in beliebiger Richtung traversiert werden. RelationshipType ist in diesem Kontext einfach nur eine Aufzählung aller bisher verwendeten Typen von Kanten wie z.B. "LINK" im Beispielgraphen. Angelegt werden Relationships über die Methode createRelationshipTo() auf einem Knoten. In Neo4j können Knoten nicht gelöscht werden, ohne dass alle mit ihnen verbundenen Relationships zuerst entfernt werden. Daher wird man beim Aufruf der Methoden zum traversieren der Relationship wie z.B. getStartNode(), getEndNode() oder getOtherNode(Node n) stets valide Knoten als Ergebnis erhalten. Andere vorhandene Methoden wie getType() und isType(RelationshipType type) unterstützen ebenfalls eine korrekte Navigation durch den Graphen. Relationships besitzen eine für das System einzigartige Id, die mit getId() abgerufen werden kann. Sollte eine Relationship allerdings mittels delete() gelöscht werden, kann das System die Id wieder neu vergeben. Path Das Interface Path repräsentiert nach [25] einen konkreten Pfad im Graphen, welcher von einem Startknoten über Relationships und Zwischenknoten zu einem Zielknoten führt. Dabei kann die minimale Pfadlänge 0 sein, sodass der Pfad nur den Start- und gleichzeitigen Endknoten enthält. Da Path von Iterable<PropertyContainer> erbt, besitzt es eine iterator()-Methode, mit der Schritt für Schritt durch Nodes und Relationships auf dem Pfad iteriert werden kann. Weiterhin sind Methoden zum Ermitteln des Start- und Endknotens verfügbar mit startNode() und endNode(). Die Länge eines Pfades kann mit length() ermittelt werden. Zusätzlich kann man sich mit nodes() und relationships() ausschließlich die Knoten oder Kanten auf dem Pfad zurückgeben lassen. GraphDatabaseService GraphDatabaseService ist nach [26] das zentrale Interface zur Kommunikation mit einer Neo4j Instanz. Es stellt die Methode beginTX() bereit, um Transaktionen zu eröffnen. Alle Operationen auf dem Graphen müssen im Kontext einer Transaktion geschehen, damit Neo4j keine ACID-Eigenschaft verletzt. Weiterhin verfügt das Interface unter anderem über Methoden, um Knoten zu erstellen (createNode()), Knoten anhand von Id oder Label zu finden (getNodeById(long id) und findNodes(Label label)), Cypher-Querys auszuführen (execute(String query)) sowie die Instanz letztlich mit shutdown() komplett herunterzufahren. 24 2.3 Neo4j Kernel API Im Gegensatz zur im vorherigen Abschnitt vorgestellten Core API, ist es (noch) nicht verbreitet Neo4js Kernel API selbst aktiv zu benutzen. Wie man in Abbildung 2.4 erkennen kann, bildet sie die Grundlage aller anderen APIs und ist demnach zwar mächtiger, aber dadurch auch komplexer und somit weniger benutzerfreundlich. Aus diesem Grund gibt es auch keine offizielle Dokumentation. Unter [27] ist das auf GitHub verfügbare Kernel-Modul zu finden, sodass man aber unter Zuhilfenahme dieser Quelle Neo4js Kernel API nutzen und verstehen kann. 1 2 3 4 5 6 7 8 // ... // inside a transaction , graphDb is an instance of G r a p h D a ta b a s e S e r v i c e T h r e a d T o S t a t e m e n t C o n t e x t B r i d g e ctx = (( GraphDatabaseAPI ) graphDb ) . g e t D e p e n d e n c y R e s o l v e r () . resolveD ependenc y ( T h r e a d T o S t a t e m e n t C o n t e x t B r i d g e . class ); Statement stm = ctx . get (); ReadOperations readOps = stm . readOperations (); 9 10 11 12 13 // Getting a node from some nodeId Cursor < NodeItem > nodeCursor = readOps . nodeCursor ( nodeId ); nodeCursor . next (); NodeItem node = nodeCursor . get (); 14 15 16 17 18 // Getting a Relationship which is connected to that node Cursor < RelationshipItem > relCursor = node . relationships ( Direction . BOTH ); relCursor . next (); RelationshipItem relationship = relCursor . get (); 19 20 21 22 // Getting the other nodes id long idOfOtherNode = relationship . otherNode ( nodeId ); // ... Abbildung 2.7: Vereinfachtes Javacode-Beispiel zur Nutzung der Kernel API Das Interface GraphDatabaseAPI ist der erste große Unterschied zur Core API. Es erbt von GraphDatabaseService und ermöglicht es, über dem in Abbildung 2.7 dargestellten Weg (Zeile 3-7) an Instanzen des Interface Statement zu kommen. Über Statements kann man an Objekte der Klassen ReadOperations für Leseoperationen (Zeile 8) oder an DataWriteOperations für Leseund Schreiboperationen gelangen. Exemplarisch für eine häufig auftretende Operation wird in den Zeilen 11-13 ein Nodeitem mit einer vorgegebenen nodeId geladen. Mit dem NodeItem hat man eine ähnliche Zugriffsmächtigkeit wie auf Node in der Core API. Allerdings sind die Methoden andere und zusätzlich muss man oftmals mit Cursor und Iterable-Klassen arbeiten. In einem weiteren Beispiel zeigen die Zeilen 16-18 wie man anhand dieses NodeItems auf ein mit diesem Knoten verbundenes RelationshipItem kommt. Ähnlich der Relationship-Klasse hat man auch hier Zugriff auf Methoden wie endNode() oder otherNode(long id) (Zeile 21). Allerdings liefert uns das nur die Id des Knoten, kein Node- oder NodeItem-Objekt. Generell macht diese API sehr viel Gebrauch von Referenzen auf Objekte via Ids (NodeIds, LabelIds, RelationshipIds, ...), Cursor- und Iterable-Klassen und arbeitet mit Javas primitiven Datentypen. 25 Kapitel 2 Grundlagen 2.3.3 Server Plugins und Unmanaged Extensions Server Plugins und Unmanaged Extensions stellen Wege dar die Funktionalität der REST API des Neo4j Servers, um seinen eigenen Code zu erweitern. Clients können via HTTP-Requests auf die neuen Funktionen zugreifen. Server Plugins Plugins sind Java-Klassen die von der Klasse ServerPlugin erben. Eine genaue Beschreibung mit Beispielen findet sich unter [28]. Um den Server mit der neuen Funktionalität auszustatten, muss der Code in eine .jar Datei gepackt, die Datei dann in den Server Klassenpfad (dem Plugins Verzeichnis im Neo4j Server Verzeichnis) kopiert und in der Konfigurationsdatei des Servers angemeldet werden. Server Plugins können nur auf ausgewählte Interfaces zurückgreifen, die Ihnen von Neo4js Server-API zur Verfügung gestellt werden. Unmanaged Extensions Unmanaged Extensions werden ähnlich wie Server Plugins benutzt, verfügen aber über die Möglichkeit beliebige JAX-RS Klassen auszuführen und können zusätzlich auf den vollständigen Neo4j Core (also alle APIs in Abbildung 2.4) zugreifen. Eine genaue Beschreibung mit Beispielen findet sich unter [29]. Es ist wichtig zu erwähnen, dass Unmanaged Extensions, im Gegensatz zu Server Plugins, selbstständig auf ihren Heap Space Verbrauch und im Zuge dessen auf Garbage Collection und daraus resultierender Performance achten müssen. 26 2.4 Graphanalyse 2.4 GRAPHANALYSE Bei der Analyse von Graphen wird versucht über der Struktur der Daten des Graphen nach Mustern zu suchen. In einem sozialen Netzwerk, könnte so ein Muster beispielsweise sein: Welche Benutzer haben besonders großen Einfluss auf das Klickverhalten anderer Benutzer? Solch ein Muster oder mehrere zusammengenommen besitzen Informationen über den Graphen, die man verwenden kann, um bessere Anfragen auf die Daten zu stellen und diese dann schneller zu beantworten. Verbreitete Felder für Graphanalysen auf großen Datenbeständen sind nach [30] unter anderem, komplexe Netzwerkanalysen, Data Mining, Computational Biology, soziale Netzwerke und Transportnetzwerke. Auf der Seite [31] findet sich eine Auflistung von Firmen, die Neo4j nutzen. Einige davon stellen ihre speziellen Anwendungen für Graphanalysen näher vor. Oft werden diese Analysen auf besonders großen, oftmals unstrukturierten, Datenmengen durchgeführt, die konventionelle Datenbanksysteme nicht ohne Probleme verwalten können. Aus diesem Grund werden externe Graph Compute Infrastrukturen wie Giraph [32] oder GraphX [33] genutzt. Giraph wird z.B. von Facebook zur Analyse ihres Benutzergraphen verwendet. 27 Kapitel 2 Grundlagen 2.5 PARALLELVERARBEITUNG Dieser Abschnitt gibt eine kurze Einführung in die Ziele, hardware- und softwaretechnischen Grundlagen und Limitierungen von Parallelverarbeitung auf einem System allgemein und bei parallelen Algorithmen. Parallelität durch eine Verteilung auf mehrere Systeme ist ebenfalls ein verbreiteter Ansatz, welcher aber im Rahmen dieser Arbeit aber nicht näher betrachtet wird. 2.5.1 Ziele Die Parallelverarbeitung zielt darauf ab Programmausführungszeiten zu optimieren (also zu minimieren), indem voneinander unabhängige Teilaufgaben gleichzeitig ausgeführt werden. Die aktuellen Multicore-Prozessoren mit ihren ständig wachsenden Kernzahlen, bieten dafür eine passende Infrastrukturplattform, welche von parallelen Programmen viel profitabler genutzt werden können als von sequenziellen Programmen. 2.5.2 Parallelität in der Hardware Neben der Parallelität in Software, um die es in dieser Arbeit geht, ist es auch wichtig, sich der physischen Gegebenheiten und der Hardware bewusst zu sein, auf denen Programme ausgeführt werden. In diesem Abschnitt wird dies betrachtet. Singlecore-Prozessoren Singlecore-Prozessoren können zu einem Zeitpunkt immer nur einen Thread ausführen. Demnach ist eine echte nebenläufige Ausführung von Programmen auf Singlecore Systemen theoretisch nicht möglich, da der Kern nach [34] einen Kontextwechsel durchführen muss. Das bedeutet, er muss den aktiven Thread abspeichern und schlafen legen um einen anderen zum Bearbeiten laden zu können. Dennoch wurden einige Techniken entwickelt um Semi-Nebenläufigkeit auf einzelnen Kernen zu unterstützen. Einige dieser Techniken aus [34] sollen im Folgenden kurz vorgestellt werden: Pipelining Jeder Maschienenbefehl setzt sich aus den Phasen fetch, decode, execute und write zusammen. Für die vollständige Umsetzung eines Befehls, müssen all diese Teilaufgaben sequenziell erfüllt werden. Um den Durchsatz an Befehlen zu erhöhen, entschied man sich dafür eine Befehlspipeline einzuführen, in der Teilaufgaben überlappend ausgeführt werden. D.h. während noch für Befehl eins decode ausgeführt wird, kann für Befehl zwei bereits fetch durchgeführt werden usw. Allerdings erzwingen Abhängigkeiten zwischen Befehlen und Sprungbefehlen im Besonderen manchmal Wartezyklen oder fordern das Rückgängigmachen von Befehlen, die aufgrund falscher Vorhersagen von Verzweigungen (branch prediction) begonnen wurden. 28 2.5 Parallelverarbeitung Pipelining ist ein komplexes Thema, für das viele Optimierungen entwickelt wurden. Weiterführende Informationen zu Pipelining sind unter [35] verfügbar. Superskalarität Superskalare Prozessoren besitzen nach [34] mehrere, gleiche, zusätzliche Funktionseinheitstypen, so dass bestimmte aufeinanderfolgende Befehle auf diese Einheiten verteilt werden können, um dann parallel bearbeitet zu werden. Hardwareseitiges Multithreading Während ein Thread auf ein Ergebnis (z.B. eines Hauptspeicherzugriffs) warten muss, bietet es sich nach [34] an, diese Zeit zu nutzen, um auf einen anderen Thread umzuschalten. Somit kann die Wartezeit effektiv genutzt werden. Um den Kontextwechsel schnell genug realisieren zu können, benötigt man im Prozessor zusätzliche Registersätze. Hardwareseitiges Multithreading wird von Intel auch Hyper-Threading genannt. Ein Prozessor mit einem Kern mit Hyper-Threading erscheint für den Benutzer als Prozessor mit zwei Kernen. Allerdings erreicht der Prozessor in der Regel keine Leistung von 200% (wie bei zwei voll ausgelasteten, physischen Kernen), sondern nur etwa 110% bis 120%. Multicore-Prozessoren Multicore-Systeme besitzen mehrere Kerne pro Prozessor, was bedeutet, dass hiermit eine tatsächliche parallele Ausführung von Programmen möglich ist. Zusätzlich dazu können die jeweiligen Kerne meistens alle Features, die im Abschnitt Singlecore-Prozessoren vorgestellt wurden, aufweisen. 2.5.3 Parallelität in der Software Parallelität in Software muss größtenteils vom Entwickler durch die Nutzung von Sprachfeatures und speziellen Bibliotheken explizit vorgegeben werden. Durch Compiler, die automatisch Code gegen effizient-parallelisierten Code austauschen, kann in der Praxis nur wenig Parallelität gewonnen werden. In den meisten Fällen müssen eigene, parallele Versionen der Algorithmen implementiert werden, die andere Denk- und Herangehensweisen an Probleme erfordern. Weiterhin eröffnen sich durch die Parallelisierung meist neue Probleme, die man im sequenziellen Ablauf nicht bedenken muss (z.B. die Synchronisation zwischen Threads). Prozesse und Threads Prozesse und Threads sind die kleinstmöglichen Einheiten, um auf einem System nebenläufige Abläufe abzuarbeiten. Auf ihnen wir im Folgenden der Fokus liegen. Prozesse Jedes auf einem Rechner ausgeführte Programm besteht aus mindestens einem Prozess, der wiederum aus einer fast beliebigen Anzahl von Threads besteht. Die Einschränkung besteht darin, dass es immer mindestens einen Hauptthread geben muss. Ein Prozess kann 29 Kapitel 2 Grundlagen aus Sicherheitsgründen nicht ohne weiteres auf Resourcen anderer aktiver Prozesse wie Speicher zugreifen. Nach [34] werden oftmals Prozesse genutzt, um eine Parallelisierung auf verteilten Systemen zu ermöglichen. Außerdem erleichtern Prozesse den Entwicklungsaufwand, weil Eigenschaften wie Speicherschutz/Speicherverwaltung bereits vorgegeben sind. Threads Threads werden laut [34] auch "unabhängige Instruktionsströme" genannt. Sie sind nebenläufige Programmteile innerhalb eines Prozesses, welche sich Speicher und Ressourcen teilen. Das heißt, dass alle Threads eines Prozesses auf die gleichen Daten zugreifen dürfen. Nur den Stack, die Registerinhalte und die Sheduling-Parameter besitzt jeder Thread für sich allein, um seine eigene Ausführbarkeit gewährleisten zu können. Primär werden Threads zur Trennung von Teilaufgaben auf den gleichen Daten genutzt. Zusätzlich stellen sie oftmals asynchrone Hintergrunddienste z.B. responsive Benutzeroberflächen bereit. 2.5.4 Grenzen der Parallelisierung Nach [34] kann man einen Algorithmus, den man beschleunigen will, aber nicht einfach mit mehr Prozessoren ausstatten oder mit dem Einsatz von mehr Threads lösen, da man wie bei jedem Problem irgendwann an Grenzen stößt. Diesem Umstand widmet sich der nachfolgende Abschnitt. Die Beschleunigung eines parallelen Programms gegenüber einer sequenziellen Implementierung wird als Speedup S in Abhängigkeit der N verwendeten Prozessoren oder Kernen bezeichnet. Er ermittelt sich, wie in der Formel 2.1 dargestellt, als Quotient aus der sequenziellen Ausführungszeit geteilt durch die Laufzeit mit N Kernen. S( N ) = T (1) T(N) (2.1) Allerdings ist der Speedup eines parallelen Programms gewissen Grenzen unterworfen, so dass er sich nicht beliebig steigern lässt. Diese Grenze ist als das Ahmdahl’sche Gesetz (Formel 2.3) bekannt. Jedes parallele Programm besitzt nach Ahmdahl einen sequenziellen und einen parallelen Anteil. Egal wie sehr wir die Laufzeit des parallelen Anteils verringern, die minimale Laufzeit wird dennoch stets vom sequenziellen Anteil bestimmt werden. Entsprechend dieser Beobachtung berechnet sich nach [34] der theoretisch maximale Speedup, wie in der Formel 2.2 anhand des parallelen Anteils P. 1 S( N )max = (2.2) 1−P Beispielsweise kann ein Programm, das zu 80% (P = 0.80) parallelisiert ist, demnach nur einen maximalen Speedup von 5 erreichen. Vermutlich wird dieser Wert aber nicht erreicht werden, denn für eine konkrete Anzahl von N Kernen oder Prozessoren sollte zusätzlich die Laufzeit P noch berücksichtigt werden. Bezieht man dies mit ein, so ergibt sich des parallelen Anteils N Ahmdahls Gesetz und damit der maximale Speedup für N Kerne oder Prozessoren, wie in Formel 2.3, folgendermaßen: 30 2.5 Parallelverarbeitung S( N )max = 1 P (1 − P ) + N ≤ 1 1−P (2.3) Die Abbildung 2.8 verdeutlicht dieses Limit. Sie zeigt die Auswirkungen dieser Formel auf die maximale Beschleunigung bei unterschiedlichen parallelen Anteilen P, welche in Abhängigkeit zu den verwendeten Kernen N stehen. Damit wird klar, dass ein einfaches Erhöhen von Prozessor- oder Kernzahlen einen parallelen Algorithmus nur bis zu einem gewissen Grad beschleunigen kann. 20 19 18 17 16 15 14 13 Smax 12 11 10 P=50% 9 P=80% 8 P=90% 7 P=95% 6 5 4 3 2 1 1 2 4 8 16 32 64 128 256 512 1024 2048 4096 N Abbildung 2.8: Speedup nach Ahmdahl 2.3 in Abhängigkeit der Anzahl der Prozessoren N und des parallelen Anteils P an der Anwendung, aus [34] Die folgende Formel 2.4 erweitert Ahmdahls Gesetz sogar noch um den nicht zu vernachlässigenden Faktor o ( N ), der den Zusatzaufwand aus Starten, Verwalten und Kommunizieren zwischen Prozessoren und Threads beschreibt. Davon ausgehend wird es quasi unmöglich den theoretisch maximalen Wert aus Formel 2.3 in der Praxis erreichen zu können. Smax = 1 (1 − P ) + o ( N ) + P N ≤ 1 1−P (2.4) Offensichtlich ist das ein sehr pessimistischer Ansatz. Die aus Ahmdahls Gesetz weiterentwickelte Theorie von Gustafson [37] ist optimistischer und bezieht den Zusammenhang zwischen Problemgröße und Prozessoren mit ein. Die Formel 2.5 stellt Gustafsons Gesetz für ein ausreichend großes Problem dar: S ( N ) = (1 − P ) + N · P (2.5) 31 Kapitel 2 Grundlagen Man sieht, dass der Speedup, im Gegensatz zu Ahmdahls Gesetz mit der Anzahl der eingesetzten Prozessoren oder Kernen mitwächst. Damit gewinnt der parallele Anteil der Berechnung an Bedeutung und so wird auch der sequenzielle Anteil der Berechnung mit zunehmender Anzahl N immer unwichtiger. 32 3 GRAPHALGORITHMEN In diesem Kapitel werden die im Projekt implementierten Graphalgorithmen vorgestellt. Zuerst soll geklärt werden, welche Zielstellung der jeweilige Algorithmus verfolgt. Danach wird jeweils ein sequenzieller und ein paralleler Algorithmus im Detail vorgestellt. Die Algorithmen und/oder deren Teilschritte werden anhand von Beispielen, welche auf der Abbildung 1.1 aus dem Abschnitt 1.3 basieren veranschaulicht. 3.1 3.1.1 RANDOM WALK Zielstellung Der Random Walk Algorithmus setzt sich zum Ziel, besonders wichtige Knoten in einem Graph zu identifizieren. In einem sozialen Netz beispielsweise kann man mit ihm besonders einflussreiche Nutzer finden. Dem Algorithmus wird das Model des zufälligen Besuchers zugrunde gelegt, welches im nächsten Abschnitt näher erläutert wird. So kann Random Walk nach [38] auch zum Ermitteln des PageRank eingesetzt werden. Die Abbildung 3.1 zeigt ein beispielhaftes Ergebnis eines Random Walks auf dem Beispielgraphen. Wie man sieht, sind die Zähler der Knoten mit besonders vielen Kanten sehr viel höher als die der anderen Knoten. Daraus lässt sich schließen, dass der Knoten "Doctor Who" für den Beispielgraphen sehr viel signifikanter ist, als z.B. "Fan". Komplexitätstheoretisch ist die Laufzeit dieses Problems nur abhängig von der vorgegebenen Anzahl an Schritten des zufälligen Besuchers, also O( AnzahlSchritte ). 33 Kapitel 3 Graphalgorithmen Abbildung 3.1: Beispiel für Ergebnis nach Random Walk 3.1.2 Sequenzieller Algorithmus In Abbildung 3.2 ist der sequenzielle Ablauf von Random Walk als Pseudocode dargestellt. Der Algorithmus endet, wenn er eine vorgegebene Menge an Schritten (NUMBER_OF_STEPS) durchlaufen hat (Zeile 1). Dieser "zufällige Besucher" startet an einem Knoten und folgt zufällig einer ausgehenden Kante zu einem nächsten Knoten (Zeile 7) oder wählt zu einer gewissen Wahrscheinlichkeit (Zeile 2, typischerweise ca. 20%) einen komplett neuen Knoten aus der Menge aller Knoten aus (Zeile 4). Der Besuch eines Knotens wird durch das Inkrementieren des Zählers am betreffenden Knoten vermerkt (Zeile 9). Macht der Algorithmus ausreichend viele Schritte, so kann man annehmen, dass die Knoten, welche einen Zählerstand über dem Wert einer statistischen Gleichverteilung über den Graph aufweisen, zu eben jenen signifikanten Knoten im Graph gehören. 1 2 3 4 5 6 7 8 9 10 11 while ( NUMBER_OF_STEPS > 0) { if ( random () <= R A N D O M _ N O D E _ P A R A M E T E R ) { // Random chance of reset currentNodeId = g e tS o m eR a nd o m No d eI d () } else { // Follow an outgoing relationship if availible currentNodeId = getNextNodeId ( currentNodeId ) } i n c r e m e n t C o u n t e r F o r I d ( currentNodeId ) NUMBER_OF_STEPS - } Abbildung 3.2: Pseudocode für Random Walk 34 3.1 Random Walk 3.1.3 Paralleler Algorithmus Da Random Walk ausschließlich lesenden Zugriff ausübt - sich Threads also nicht gegenseitig blockieren - und sein Ziel, eine gegebene Anzahl Schritte zu erreichen ist, lässt sich dieser Algorithmus sehr gut und einfach parallelisieren. Nicht zuletzt wurde sich in dieser Arbeit wegen der Unabhängigkeit zwischen den Threads für Random Walk entschieden. Denn dadurch lässt sich experimentell die Parallelsierbarkeit einer Schnittstelle ermitteln. Für die Parallelisierung von Random Walk mit n Threads startet man n mal den sequenziellen Algorithmus mit der folgenden Änderung am Parameter NUMBER_OF_STEPS : NU MBER_OF_STEPSneu = NU MBER_OF_STEPS alt n Die Zählerstände jedes Threads werden nach Beendigung aller Threads einfach zusammengezählt. Diese Summen bilden das finale Ergebnis. An der Komplexitätsbetrachtung ändert sich dabei nichts. Hinzu kommt nur der Mehraufwand für das Verwalten der zusätzlichen Threads. 35 Kapitel 3 Graphalgorithmen 3.2 3.2.1 WEAKLY CONNECTED COMPONENTS Zielstellung Der Algorithmus Weakly Connected Components (WCC) findet zusammenhängende Subgraphen (Komponenten) im Graph. Eine schwache Zusammenhangskomponente zeichnet sich dadurch aus, dass von jedem Knoten der Komponente einen Pfad zu jedem anderen Knoten der Komponente existiert. Dabei wird die Richtung der Kanten vernachlässigt, denn nur ihre Existenz ist von Bedeutung. WCC basiert stark auf der Breitensuche ("Breadth First Search", BFS). Abbildung 3.3 zeigt ein beispielhaftes Ergebnis von Weakly Connected Components auf dem leicht modifizierten Beispielgraphen. Alle irgendwie miteinander verbundenen Knoten sind hier der gleichen Komponente (hier: 0 oder 1) zugeordnet. Abbildung 3.3: Beispiel für Ergebnis nach Weakly Connected Components 3.2.2 Sequenzieller Algorithmus Der sequenzielle Algorithmus für Weakly Connected Components (WCC) ist in Abbildung 3.4 als Pseudocode dargestellt. Er arbeitet solange bis alle Knoten einer Komponente zugeordnet sind (Zeile 2). Gibt es einen Knoten, der noch zu keiner Komponente gehört, wird von ihm aus eine Breitensuche gestartet (Zeile 4). Die daraus resultierende Knotenmenge wird als neue Komponente vermerkt (Zeile 5). Wie bereits eingangs erwähnt, ist die Breitensuche das Herzstück dieses Algorithmus. Daher betrachten wir sie nun etwas näher. In Abbildung 3.5 ist eine Pseudocode-Implementation der Breitensuche gegeben. Für jede Breitensuche gibt es ein Set visitedIDs, in welchem bereits untersuchte KnotenIds abgelegt werden. Zusätzlich dazu gibt es eine Liste frontierList, welche die noch zu expandierenden Knoten beinhaltet. Der Algorithmus startet an einem gegebenen Knoten (der ursprünglichen nodeId) und läuft bis kein Element mehr in der frontierList vorhanden ist 36 3.2 Weakly Connected Components 1 2 3 4 5 6 7 componentId =0 // uses as name of the component while ( unvisitedIdExist ()){ Long n = getUnvisitedId () Set < Long > resultSet = BFS (n , Direction . BOTH ) markAsComponent ( resultSet , componentId ) componentId ++ } Abbildung 3.4: Pseudocode für Weakly Connected Components (Zeile 8). Existiert ein Element in der Liste, wird es aus ihr entfernt (Zeile 9) und die damit verbundenen Knoten werden zusammengetragen (Zeile 10). Nur wenn es sich bei diesen Kindknoten um noch unbesuchte Knoten handelt, werden sie in die frontierList aufgenommen (Zeile 11-15). Bei der allgemeinen Breitensuche werden, nach [40], die neu expandierten Knoten ans Ende der frontierList gesetzt, so dass sich die charakteristische Suche in die Breite im Suchbaum ergibt. Jedes Level wird somit nacheinander vollständig abgearbeitet. Alle durch diesen Algorithmus erreichten IDs werden als Ergebnis zurückgeliefert (Zeile 20). Komplexitätstheoretisch ist die Laufzeit dieses Algorithmus analog zur Breitensuche (siehe [39])] abhängig von der Anzahl an Knoten (V) und Kanten (E) des Graphen, O(|V | + | E|). 1 2 3 Set < Long > BFS ( long nodeID , Direction direction ){ Set < Long > visitedIDs List < Long > frontierList 4 frontierList . add ( nodeID ) visitedIDs . add ( nodeID ) 5 6 7 while ( frontierList . isNotEmpty ()){ Long node = frontierList . remove (0) for ( Long child : g et C o nn e ct e d No d eI d s ( node , direction )){ if ( visitedIDs . contains ( child )){ continue } else { // node is not visited yet visitedIDs . add ( child ) frontierList . add ( child ) } } } return visitedIDs 8 9 10 11 12 13 14 15 16 17 18 19 20 21 } Abbildung 3.5: Pseudocode für BFS 3.2.3 Paralleler Algorithmus Die Kernidee des parallelen Algorithmus ist es, die Expansion der Knoten nebenläufig zu betreiben. Da der Hauptteil der Arbeit des sequenziellen WCC Algorithmus von Breitensuchen bestimmt wird, erscheint es sinnvoll zu versuchen diesen Teil (die Suche) des Algorithmus zu 37 Kapitel 3 Graphalgorithmen parallelisieren. Der BFS aufrufende Teil des Algorithmus aus Abbildung 3.4 muss dazu kaum verändert werden. Lediglich die Suche muss ausgetauscht werden. Zu diesem Zweck wurde nach [41] eine Level-parallele Breitensuche entworfen. Abbildung 3.6 stellt sie als Pseudocode dar. Wie in Abschnitt 3.2.2 beschrieben, werden die Knoten durch die allgemeine Breitensuche stets zuerst auf Levelebene expandiert. Im Gegensatz dazu, wird in dieser Implementierung Parallelität während der Exploration eines Levels genutzt, damit hier ein Thread (MyBFS-Thread) nicht die komplette frontierList alleine untersuchen muss. Solange Elemente in der frontierList existieren (Zeile 5), wird die Subroutine doParallelLevel aufgerufen (Zeile 7). Diese startet in Abhängigkeit der Größe der frontierList n Threads, welche dann eine vorgegebene Menge von Knoten (Parameter BATCHSIZE, siehe auch Abschnitt 4.2) aus der frontierList um eine Ebene expandieren (Zeile 14-19). Die Threads liefern so eine Ergebnismenge, die aus allen neu entdeckten Knoten besteht. Im Main-Thread werden alle Ergebnisse der Threads eingeholt und zu einer neuen frontierList zusammengefasst (Zeile 25). Bereits expandierte Knoten werden dabei natürlich nicht noch einmal betrachtet (Zeile 28). Gegenüber dem sequenziellen Algorithmus ändert sich die Laufzeitkomplexität von O(|V | + | E|) nicht. 1 2 3 Set < Long > parallel_BFS ( long nodeID , Direction direction ){ visitedIDs . clear () frontierList . add ( nodeID ) 4 while (! frontierList . isEmpty ()) { doParallelLevel ( direction ) } return visitedIDs 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 } void doParallelLevel ( Direction direction ){ int pos = 0 int tasks = 0 while ( pos < frontierList . size ()){ startMyBFSThread ( pos , BATCHSIZE , frontierList . size () , frontierList , direction ) pos = pos + BATCHSIZE tasks ++ } visitedIDs . addAll ( frontierList ) frontierList . clear () 22 // threads finished , collecting results for new frontier for ( int i =0; i < tasks ; i ++){ frontierList . addAll ( ge tResultO fThread ( i )) } frontierList . removeAll ( visitedIDs ) 23 24 25 26 27 28 } Abbildung 3.6: Pseudocode für paralleles BFS 38 3.3 Strongly Connected Components 3.3 3.3.1 STRONGLY CONNECTED COMPONENTS Zielstellung Im Gegensatz zum im vorherigen Abschnitt beschriebenem WCC, ist für den Algorithmus zum Auffinden von Strongly Connected Components die Richtung einer Kante von Bedeutung. Es muss nach [42] von jedem Knoten aus ein gerichteter Pfad zu jedem anderen Knoten der Komponente existieren, damit sie als starke Zusammenhangskomponente (SCC) gilt. Abbildung 3.7 zeigt ein beispielhaftes Ergebnis von Strongly Connected Components auf dem Beispielgraphen. Abbildung 3.7: Beispiel für Ergebnis nach Strongly Connected Components 3.3.2 Sequenzieller Algorithmus Einer der bekanntesten, verbreitetsten und effizientesten sequenziellen Algorithmen zum Auffinden von SCCs ist der Algorithmus von TARJAN. Die Abbildung 3.8 zeigt den Algorithmus von TARJAN nach [44]. Die Kernidee von TARJANs Algorithmus ist, durch Tiefensuchen auf dem Graphen zusammen mit einem geschickten Einsatz eines Kellerspeichers alle SCCs im Graphen aufzuzeigen. Dabei funktioniert der Algorithmus folgendermaßen: Es wird ein Tiefensuchezähler maxdfs angelegt und auf 0 gesetzt (Zeile 2). Danach wird aus der Menge der unbesuchten Knoten ein Knoten ausgewählt. Auf diesem wird die (nachfolgend beschriebene) Prozedur tarjan() aufgerufen (Zeile 6). Diese beiden Schritte werden wiederholt, bis die Menge der unbesuchten Knoten leer ist (Zeile 5). 39 Kapitel 3 Graphalgorithmen 1 2 3 4 5 6 7 8 void scc ( Set < Node > V , Set < Relationship > E ){ int maxdfs = 0 // Counter for dfs Set < Nodes > U = V // Set of unvisited nodes Stack S // Empty stack while ( U . isEmpty ()== false ){ tarjan ( getUnvisitedNode ()) } } 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 void tarjan ( Node v ){ v . dfs = maxdfs v . lowlink = maxdfs maxdfs = maxdfs ++ S . push ( v ) U . remove ( v ) for ( Node w in ge tConnect edNodes ( v )) { if ( U . contains ( w )){ tarjan ( w ) // recursive call v . lowlink = min ( v . lowlink , w . lowlink ) } elseif ( S . contains ( w )){ v . lowlink = min ( v . lowlink , w . dfs ) } } if ( v . lowlink == v . dfs ){ // Root of a new SCC SCC newSCC do { Node n := S . pop () newSCC . add ( n ) } while ( n != v ) saveSCC ( newSCC ) } } Abbildung 3.8: Pseudocode für Strongly Connected Components Die Methode tarjan() bekommt einen Knoten v. Jeder Knoten besitzt zwei Zähler. Der erste Zähler dfs beschreibt ausgehend vom Startknoten die Tiefe des Knotens im Baum. Der zweite Zähler lowlink, wird benutzt, um Kanten zu bekannten erreichbaren Knoten mit minimalen maxdfs-Zähler anzuzeigen. So weißt lowlink auf SCCs hin. Beide Zähler werden in Zeile 11 und 12 auf den Wert von maxdfs gesetzt. Da wir als nächstes die Kinder von v untersuchen werden, wird maxdfs an dieser Stelle inkrementiert (Zeile 13). In Zeile 14 wird dann der aktuelle Knoten v auf den Stack (Kellerspeicher) gelegt und aus der Menge der unbesuchten Knoten U entfernt (Zeile 15). Nun beginnt die Untersuchung der mit v verbundenen Knoten. Für jeden dieser Knoten w (Zeile 16), muss geprüft werden ob er bereits besucht wurde (Zeile 17). Ist dies nicht der Fall, so wird rekursiv die Methode tarjan(w) aufgerufen. Kehrt man aus diesem Aufruf zurück so wird lowlink von v als Minimum des aktuellen lowlink und des lowlink des Knoten w gebildet (Zeile 19). Ist der Knoten w allerdings bereits besucht worden und liegt auf dem Stack (Zeile 20), so bildet sich der lowlink von v als Minimum des lowlink von v und des Zählers dfs von w (Zeile 21). Durch die rekursive Arbeitsweise des Algorithmus im Zusammenspiel mit dem Stack, können wir nun in Zeile 24 herausfinden, ob der aktuelle Knoten v die Wurzel einer SCC ausmacht. Für diese gilt, dass der Wert von lowlink dem von dfs entsprechen muss. Ist hierbei solch eine Wurzel gefunden worden, so gehören dank Zeile 14 alle Knoten die oben auf dem Stack liegen 40 3.3 Strongly Connected Components und nicht v sind (und v selbst) zu einer SCC (Zeile 25-30). Abbildung 3.9: Beispiel für zusammengefasste Ausführung von tarjan() mit Startknoten A Die Abbildung 3.9 illustriert in zusammengefasster Form die Arbeitsweise der Methode tarjan() anhand des in Bild 1 dargestellten Graphen. Zuerst wird tarjan(A) aufgerufen, welcher maxdfs und lowlink setzt, A auf den Kellerspeicher (rechts dargestellt) legt. Danach wird tarjan(B) aufgerufen. Dies setzt sich bis zum Knoten D fort. Hier existiert nur eine Kante zu einem bereits bekannten Knoten, so dass sich der korrekte lowlink von D als Minimum des maxdfs-Zählers von B und dem ursprünglichen lowlink von D (4) ergibt (in unserem Beispiel ist das 1). Da keine Kanten mehr von D aus zu bearbeiten sind sowie die Zähler lowlink und maxdfs nicht übereinstimmen, ist Knoten D abgearbeitet. Diese Situation ist in Bild 2 dargestellt. Als nächstes kehrt der Aufruf von tarjan(D) von Knoten C zurück. Dabei setzt er als neuen lowlink das Minimum aus dem eigenem lowlink und dem lowlink von D (also wieder 1). Ohne weitere zu untersuchende Kanten von C und ohne übereinstimmende Zähler ist der Aufruf von tarjan(C) beendet. Der Knoten B setzt nun ebenfalls seinen lowlink analog zu C. Nachdem er keine Kanten mehr untersuchen kann, aber beide Zähler übereinstimmen, wird dieser Knoten die Wurzel einer SCC erkannt. Nun müssen so lange Knoten vom Kellerspeicher genommen, bis der Knoten B entfernt wird. Alle diese Elemente gehören zur gefundenen SCC (grün markiert). Dieser Zustand ist in Bild 3 veranschaulicht. In Bild 4 kehrte der Aufruf tarjan(B) zurück. Analog zu B stellt Knoten A fest, dass er eine Wurzel einer SCC bildet, zu der er aber nur allein zugehörig ist (grün markiert). 41 Kapitel 3 Graphalgorithmen So ist die theoretische Laufzeit dieses Algorithmus nach [44] abhängig von der Anzahl an Knoten (V) und Kanten (E) des Graphen, also O(|V | + | E|). 3.3.3 Paralleler Algorithmus Als Version des parallelen Algorithmus für Strongly Connected Components wurde sich für den Multistep-Algorithmus nach [41] entschieden, da er mehrere Teilschritte, die für sich genommen bereits verbreitete parallele Algorithmen für dieses Problem sind, verknüpft. Zusammengenommen besitzen sie eine höhere Effizienz1 . Gegenüber dem sequenziellen Algorithmus ändert sich die Laufzeitkomplexität von O(|V | + | E|) nicht. Die Abbildung 3.10 zeigt den Multistep-Algorithmus nach [41] als Pseudocode. In den nachfolgenden Abschnitten werden die vier Phasen des Algorithmus näher beleuchtet. In Phase 1 findet ein simples Trimming des Graphen statt (Zeile 2). Dabei geht es darum, triviale SCCs, also Knoten ohne ein- oder ausgehende Verbindungen, automatisch zu erkennen und bereits an dieser Stelle schon zu verarbeiten. Phase 2 ist ein einziger Forward-Backward Sweep (Zeile 5), der die größte Komponente des Graphen finden und verarbeiten soll. In Phase 3 finden Multistep-Coloring-Verfahren statt (Zeile 10), solange bis die Nummer an Knoten des Graphen, die noch keiner SCC zugewiesen sind, unter den Wert eines Parameters NCutoff fällt (Zeile 9). Die restlichen Knoten werden abschließend vom sequenziellen TARJAN-Algorithmus verarbeitet (Zeile 14). Laut [41] ist der optimale Wert von NCutoff von der Topologie des zu untersuchenden Graphen abhängig (siehe auch Abschnitt 4.2). Alle, während dieser Phasen, gefundenen SCCs werden ähnlich wie in Zeile 6 aus der Menge der noch zu untersuchenden Knoten entfernt. 1 2 // Phase 1 trim () 3 4 5 6 // Phase 2 nodeSet = FWBW_Step ( myBFS ) saveSCC ( nodeSet ) // nodeSet will be removed from nodes 7 8 9 10 11 // Phase 3 while ( NCutoff < nodes . size ()) { MSColoring () } 12 13 14 // Phase 4 tarjan () Abbildung 3.10: Pseudocode für paralleles Strongly Connected Components (Multistep nach [41]) Trim Trim ist eine Subroutine, die Knoten mit einem In- oder Out-Degree (also wie viele ein- und ausgehende Kanten der Knoten besitzt) von null als triviale Komponenten erkennt und diese 1 siehe 42 Vergleich in [41] 3.3 Strongly Connected Components abspeichert. Für die weiteren Schritte reduziert sich damit die zu prozessierende Knotenmenge. Abbildung 3.11 zeigt das Ergebnis von Trim auf unserem Beispielgraphen. Abbildung 3.11: Beispielgraph nach Einsatz von Trim Forward-Backward Sweep Der Teilschritt Forward-Backward Sweep soll die größte Komponente des Graphen auffinden und untersuchen. Dazu wird ein Knoten w gewählt, der in dieser Komponente liegen sollte. Als Heuristik benutzen die Autoren von [41] das Produkt aus In- und Out-Degree eines Knotens. Laut Ihnen ist der Knoten, für den dieses Produkt maximal ist, mit großer Wahrscheinlichkeit Teil der gesuchten Komponente. Als nächstes wird eine Knotenmenge D berechnet, die sich aus einer parallelen Breitensuche (wie in Abschnitt 3.2.3 beschrieben) ausgehend vom Knoten w auf der Kantenmenge der ausgehenden Kanten ergibt. Nun berechnet man eine Knotenmenge M über eine parallele Breitensuche auf den eingehenden Kanten mit w als Startknoten und der Einschränkung, dass hierbei nur Knoten betrachtet werden, die in D liegen. Schneidet man die Mengen D und M miteinander, so erhält man die gesuchte starke Zusammenhangskomponente. Multistep-Coloring MS-Coloring findet und verarbeitet SCCs durch eine Kombination aus sich durch den Graphen propagierender Knotenbeschriftungen und einer Rückwärtssuche auf diesen nun "bemalten" Knoten. Er besteht aus 3 Teilschritten und ist in Abbildung 3.12 als Pseudocode dargestellt. Es beginnt mit einem iterativem parallel coloring, welches jedem Knoten eine "Farbe", die selbst je einer KnotenIDs entspricht, zuweist. Als Startsituation (nicht dargestellt) wird jedem Knoten seine eigene ID als Farbe zugeordnet und danach werden alle Knoten in die Queue gepackt. Ein Thread, der iterative parallel coloring ausführt, erhält eine Teilmenge aller Knoten abhängig vom Parameter BATCHSIZE (Zeile 5-9). Nun betrachtet dieser Thread jede Kante zwischen dem ihm vorgegebenem Knoten v und jedem verbundenen Knoten w. Falls die Farbe (und damit die ID) 43 Kapitel 3 Graphalgorithmen von v größer als die Farbe von w ist, wird dem Knoten w die Farbe von v zugewiesen. Beide IDs werden dann in eine lokale Ergebnismenge geschrieben. Die Ergebnismengen aller Threads werden nach Abarbeitung der Threads, vom Mainthread eingesammelt (Zeile 12-14). Danach werden diese als Next-Level Queue für die nächste Iteration verwendet. Verändert sich also bei einem der Threads die Farbe von nur einem Knoten, so wird nach der Abarbeitung aller Threads iterativ parallel coloring mit den sich geänderten Knoten erneut gestartet (Zeile 1). Nach [41] ist es dabei kein Problem welcher Thread, welche Relationship untersucht. Es können keine Race Conditions2 auftreten, da Knoten stets ausschließlich mit höheren Farben bemalt werden. Entsprechend erinnert dieser Teilschritt an den Label Propagation Algorithmus [45]. Als nächstes werden für jede Farbe c die jeweiligen Knotenmengen Vc vorbereitet. Diese enthalten nur Knoten, die mit der entsprechenden Farbe c bemalt wurden (Zeile 18-20). Abschließend startet man ausschließlich auf allen eingehenden Kanten und der Menge der Knoten Vc eine parallele Breitensuche (wie in Abschnitt 3.2.3 beschrieben). Dabei wird als Ausgangsknoten der Knoten dessen ID mit der Farbe c übereinstimmt gewählt (Zeile 26-31). Das Ergebnis dieser Breitensuche entspricht der gesuchten SCC (Zeile 32-35). 2 Situation, 44 in der das Ergebnis einer Operation vom zeitlichen Verhalten bestimmter Teiloperationen abhängig ist 3.3 Strongly Connected Components 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 while ( Queue . size ()!=0) { // iterative parallel colorings tasks =0 pos =0 while ( pos < Queue . size ()){ s ta r tC o l or i ng T h re a d ( pos , BATCHSIZE , Queue . size () , Queue ) pos = pos + BATCHSIZE // new startPos = old EndPos tasks ++ } Queue . clear () // Barrier synchronization for ( int i =0; i < tasks ; i ++){ Queue . addAll ( g e t R e s u l t O f C o l o r i n g T h r e a d ( i )) } } 16 17 18 19 20 // prepare sets of nodes for each color for ( Node n : allNodes ){ addToNodeSetDependingOnColor (n) } 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 // start parallel BFS on incomming relationships , starting at node c for ( Color c : allColors ){ pos = 0 tasks = 0 while ( pos < allColors . size ()){ startMyBFSThread ( pos , BATCHSIZE , allColors . size () , allColors , Direction . INCOMMING , ge tN ode Se tF orC ol or ( c )) pos = pos + BATCHSIZE tasks ++ } for ( int i =0; i < tasks ; i ++){ Set < Node > scc = SCC . addAll ( getResul tofThrea d ( i )) } saveSCC ( scc ) } Abbildung 3.12: Pseudocode für MS-Coloring 45 Kapitel 3 Graphalgorithmen 46 4 PROJEKTAUFBAU UND IMPLEMENTIERUNG 4.1 PROJEKTAUFBAU Abbildung 4.1: Vereinfachter Projektphasenaufbau (rechts, blau) mit Eingliederung in Neo4js APIs (links) nach [12] Der konzeptuelle Phasenaufbau des Analysealgorithmen-Projektes (blau), zusammen mit einer Eingliederung des Projektes in Neo4js APIs, ist in Abbildung 4.1 dargestellt. Wie man erkennen kann, ist der normale OLTP-Workload (grün) vom Benutzer an die verschiedenen APIs (Cypher, Traverser API, Core API) unabhängig von der Aktivierung und Benutzung der Analysealgorith- 47 Kapitel 4 Projektaufbau und Implementierung men Extension. Diese wird vom Benutzer via GET-Request (rot) an eine, von der Extension selbst definierte, REST API gestartet. Die Erweiterung ist in der Abbildung vereinfacht als dreiteiliger Prozess dargestellt und wird nachfolgend näher erläutert. Über eine Starter-Klasse werden Informationen ermittelt, die zur konkreten Auswahl und Parametrisierung der Algorithmen genutzt werden. Sie bildet die Schnittstelle zwischen REST API und den auszuführenden Algorithmen. Zum Beispiel muss übersetzt werden welches Problem (RW, WCC, SCC) ausgewählt wurde, wie viele Threads dafür genutzt werden sollen. wie viele Schritte Random Walk erledigen soll, usw. Außerdem ist die Starter-Klasse dafür verantwortlich, ein Warmup der Datenbank auszuführen, welches im Abschnitt 4.2 näher vorgestellt wird. Als nächstes übernimmt der jeweilig ausgewählte Algorithmus die Kontrolle. Natürlicherweise beansprucht dieser Schritt den allergrößten Teil der Projektlaufzeit. Dabei ist zu bemerken, dass während dieser und der vorhergehenden Phase ausschließlich lesender Zugriff auf die Datenbank ausgeübt wird. Auch intern werden durch Neo4j keine Schreibsperren auf Knoten oder Kanten gesetzt. Eventuell parallel laufende schreibende Transaktionen aus externen Quellen werden demnach in keinster Weise negativ beeinflusst. Abschließend werden die Ergebnisse des Algorithmus parallel über den NeoWriter-Schritt als Properties an die Knoten in der Datenbank geschrieben. Dieser Schritt nimmt dank der Schreibunterstützung von Neo4j nur minimale Zeit in Anspruch, so dass Knoten für parallel laufende Transaktionen nicht lange gesperrt sind. Details zu NeoWriter finden sich im nächsten Abschnitt, der sich mit Implementierungsdetails befasst. Um die maximale Leistungsfähigkeit des jeweiligen Algorithmus zu nutzen, wurden alle Algorithmen unter Nutzung der Kernel API implementiert. Zur Demonstration der Unterschiede zwischen den beiden in Abschnitt 2.3.2 vorgestellten APIs (siehe Kapitel 5), wurde Random Walk sowohl mit der Kernel API als auch der Core API implementiert. 48 4.2 Implementierung 4.2 IMPLEMENTIERUNG In diesem Kapitel werden einige Bemerkungen und Details zur Implementierung gegeben, die noch nicht in Kapitel 3 abgedeckt wurden. Verwendete Bibliotheken und Versionen Für die Implementierung dieses Projektes und die Aufnahme der Messreihen wurde mit Neo4j in der Version 2.3.2 (Enterprise Version) gearbeitet. Das Projekt benutzt Java in der Version 1.7. Außerdem werden die Fremdbibliotheken guava [46] in der Version 18.0 für ihre Stopuhr, HPPC [47] in Version 0.7.1 für ihre PrimitiveCollections und HdrHistogram [48] in der Version 2.1.6 für die Aufnahme von Messwerten und Darstellung als Histogramm verwendet. Trim für WCC Mit einer kleinen Anpassung kann man Trim, wie es in Abschnitt 3.3 beschrieben ist, auch für Weakly Connected Components verwenden. Hierfür muss man beim Trim nur darauf testen, ob Knoten über einen In- und Out-Degree von null verfügen um als triviale Komponenten zu qualifizieren. Für die Implementierung wurde sich dafür entschieden, Trim ebenfalls für WCC einzusetzen. Da dieser Vorverarbeitungsschritt die Laufzeiten verringert, er aber keinen Laufzeitunterschied zwischen parallelen und sequenziellen Algorithmus verursacht. Parameter BATCHSIZE Der Parameter BATCHSIZE wird im Projekt genutzt, um einzustellen, wie viele Operationen pro Thread ausgeführt werden. Beispielsweise bekommen MyBFS-Threads Listen mit KnotenIds der Größe von BATCHSIZE um sie zu expandieren. Eine experimentelle Bestimmung des optimalen Wertes für BATCHSIZE wird in Abschnitt 5.4 vorgenommen. Parameter NCutoff Vom Parameter NCutoff ist abhängig, wie groß der Anteil der Phase 3 (MS-Coloring) im Multistep Algorithmus ist. Nach [41] ist er hardwarespezifisch und an den zu untersuchenden Graphen anzupassen, um optimale Laufzeiten für Multistep zu erhalten. Da die optimale Wahl des Parameters nicht im Fokus der Arbeit liegt, wurde der Wert, nach ein paar Testläufen, für alle Testreihen auf 1000 gesetzt. Dieser Wert muss nicht dem Optimum entsprechen. Eine bessere Wahl des Parameters würde also noch potentiell höhere Speedup-Werte zur Folge haben. 49 Kapitel 4 Projektaufbau und Implementierung Ermittlung der Heuristik zur Auswahl des Startknotens für FW-BW Sweep Da Trim eine komplette Iteration durch alle Knoten des Graphen und die Untersuchung der Inund Out-Degrees der Knoten erfordert, kann man in diesem Schritt nebenbei die Heuristik zur Auswahl des Startknotens für den in Abschnitt 3.3 beschriebenen Forward-Backward Sweep ermitteln. Dies ist nur ein verschwindend geringer Mehraufwand. Da alle benötigten Informationen bereits geladen sind und somit pro Knoten nur eine Multiplikation und ein Vergleich zusätzlich von Nöten sind. In der implementierten Version ist Trim, zusätzlich zur eigentlichen Funktion, in der Lage den Knoten mit dem maximalen Produkt aus In und Out-Degree zu liefern. Warmup Um die Zeitmessung der Algorithmen von der Latenz der Festplatte zu entkoppeln und somit den Plattenzugriff zu minimieren, wird mittels des Warmups die komplette Datenbank in den Arbeitsspeicher geladen. Dies hat es notwendig gemacht, einen zusätzlichen Schritt im Projektablauf hinzuzufügen. Der Warmup-Schritt muss vor der Durchführung des Algorithmus abgeschlossen sein. Dabei läd er jede benötigte Neo4j-Datenbankseite in den RAM. Neo4j legt seine Seiten in sogenannte Stores ab, so dass für einen speziellen Record der jeweilige Store angefragt werden muss (Beispiele: Relationship-Store für RelationshipRecords, Node-Store für NodeRecords usw.). Folgt man dem intuitiven Ansatz, muss man nun das Warmup so implementieren, dass es durch die jeweiligen benötigten Stores iteriert und dort alle Records lädt. Allerdings kann man diesen Ablauf um einiges beschleunigen, wenn man sich mit den physischen Spezifikationen von Neo4j beschäftigt. In Neo4j besitzen Datenbankseiten per default jeweils eine feste Größe von 8KB. Ein NodeRecord umfasst nur 15 Byte und ein RelationshipRecord verfügt über 34 Byte. Demnach umfasst eine Seite im NodeStore 533 NodeRecords und eine Seite im RelationshipStore 235 RelationshipRecords. Die physische Ordnung der Records entspricht dabei der Ordnung der Ids. Da Neo4j nur in Einheiten von Seiten mit der Festplatte kommuniziert, reicht es demnach aus, einen Record einer jeden Seite zu laden, um alle Seiten eines Stores in den Arbeitsspeicher zu bewegen. Abbildung 4.2 zeigt den Pseudocode für das Laden des gesamten NodeStores unter Ausnutzung des beschriebenen Effektes. Die dort benutzte highestNodeID wird von jedem Store stets aktuell bereit gehalten und lässt sich recht trivial abfragen. Natürlich muss für diesen Ansatz das ausführende System über genügend Arbeitsspeicher verfügen, um dort die komplette Datenbank bzw. alle relevanten Stores aufzunehmen. Zusätzlich kann und sollte Neo4j über einen PageCache von mindestens der Datenbankgröße verfügen. Diesen Wert kann man nach [49] über die Server-Konfigurationsdatei /conf/neo4j.properties anpassen. 50 4.2 Implementierung 1 2 3 4 5 for ( int i =0; i <= highestNodeID ; i = i +533) { loadNode ( i ); } // All node pages are in memory now Abbildung 4.2: Pseudocode für Warmup des NodeStores NeoWriter NeoWriter werden im Projekt dazu genutzt um Ergebnisse in Form von Propertys zurück an die Knoten des Graphen zu schreiben. Die Ergebnisse eines Algorithmus werden in der Form einer Zuordnung von KnotenId auf einen numerischen Wert (Komponentennummer, Random Walk Zähler) geliefert. Ein NeoWriter schreibt davon BATCHSIZE viele Ergebnisse zurück in die Datenbank. Es laufen n NeoWriter-Threads parallel, wobei n die vom Benutzer oder System vorgegebene Anzahl an zu benutzenden Threads darstellt. Somit ist der Vorgang des Zurückschreibens in die Datenbank ebenfalls parallelisiert. Primitive Collections Java behandelt primitive Datentypen (z.B. int, long, float, ...) in Collections (z.B. List, Set und Map) wie Objekte. Sowohl für die Performance, als auch für den Speicherverbrauch, ist das nicht die optimale Lösung. Aus diesem Grund wurde sich dafür entschieden alle Collections in der Implementierung, die mit primitiven Datentypen umgehen, durch PrimitiveCollections aus der HPPC Bibliothek [47] auszutauschen. REST API Die Tabelle in Abbildung 4.3 beschreibt die Aufrufsyntax für die, von der Extension definierte REST API. Anhand von Beispielaufrufen auf dem fiktiven System "server", dessen Neo4jInstanz auf dem Port 7474 lauscht, werden die nötigen Parameter demonstriert. Der MountPoint muss in der Datei "neo4j-server.properties" im Neo4j-Verzeichnis "conf" angegeben werden. Weitere Details zur Inbetriebnahme einer Extension befinden sich unter [29]. Der Parameter PropertyName definiert den Namen der neuen Eigenschaft, die an die Knoten geschrieben wird. Die WriteBatchsize beschreibt wie viele Schreiboperationen ein Thread ausführt und wird zur Konfiguration der NeoWriter genutzt. Statt Number_of_Threads manuell festzulegen, kann auch der Wert "auto" genutzt werden. Dies setzt den Wert des Parameters auf die Anzahl an verfügbaren CPU-Kernen. 51 Kapitel 4 Projektaufbau und Implementierung Algorithmus Aufrufsyntax mit Beispiel http://server:port/mountpoint/warmup Warmup Random Walk http://server:7474/extension/algorithms/warmup http://server:port/mountpoint/randomWalk /PropertyName/Batchsize/Number_of_Threads /WriteBatchsize/ Number_of_Steps/KernelAPI Weakly Connected Components http://server:7474/extension/algorithms/randomWalk /counter/1000/4 /1000/100000/true http://server:7474/extension/algorithms/randomWalk /counter/1000/4 /1000/100000/false http://server:port/mountpoint/weaklyConnectedComponents /PropertyName/Batchsize/Number_of_Threads/WriteBatchsize Strongly Connected Components http://server:7474/extension/algorithms/weaklyConnectedComponents /WeaklyComponentId/1000/4/1000 http://server:port/mountpoint/stronglyConnectedComponents /PropertyName/Batchsize/Number_of_Threads/WriteBatchsize (mit Kernel API) (mit Core API) http://server:7474/extension/algorithms/stronglyConnectedComponents /StronglyComponentId/1000/auto/1000 Abbildung 4.3: Aufrufsyntax für REST API mit Beispielen 52 5 MESSUNGEN Dieses Kapitel befasst sich mit den Ergebnissen der Messungen der Implementierungen aller in Kapitel 3 vorgestellten Algorithmen. Der erste Abschnitt dieses Kapitels beleuchtet außerdem die Testbedingungen unter denen die Messwerte aufgenommen wurden. 5.1 TESTBEDINGUNGEN Um die Ergebnisse nachvollziehbar zu halten, werden in diesem Abschnitt die physischen Bedingungen der Messungen beleuchtet. Im Besonderen geht es um die tatsächlich gemessenen Datensätze, auf denen die Algorithmen gearbeitet haben und die Hardware, die dabei zum Einsatz kam. Alle gemessenen Werte wurden durch die Version der Implementierung aufgenommen, die Neo4j als EmbeddedDatabase benutzt. Da hier eigene, neue Java Virtuelle Maschinen gestartet werden, bietet diese Version die größeren Freiheiten in der Konfiguration des Systems. Deshalb wurde sich dafür entschieden, die Messwerte mit dieser Version aufzunehmen. Die Implementierungsversion, die eine Extension mit REST API definiert, hätte aber natürlich stattdessen gewählt werden können und würde bei korrekter Konfiguration der Datenbank identische Ergebnisse liefern. Graphipedia Graphipedia [50] ist ein Java-Projekt, mit dem man aus einer Wikipedia Speicherauszugsdatei (dump file) eine Neo4j-Datenbank generieren kann. Dabei werden Wikipediaseiten als Knoten abgelegt und die Hyperlinks dieser Seite zu anderen Seiten als "Link"-Relationships angelegt. Die Knoten besitzen nur die Property "title", welche den Namen der Wikipedia Seite beinhaltet. Zur Veranschaulichung des Graphipedia-Datenmodells sei auf Abbildung 1.1 verwiesen, in dem ein unvollständiger Ausschnitt des durch Graphipedia generierten Graphen der deutschen Wikipedia dargestellt ist. 53 Kapitel 5 Messungen Genutzte Datenbanken Für die Messungen wurden zwei Datenbanken verwendet, die im folgenden als deWiki und enWiki bezeichnet werden. deWiki ist eine durch eine Speicherauszugsdatei der deutschen Wikipedia mit Graphipedia generierte Neo4j Datenbank. Sie besitzt eine physische Größe von 2,02 GB und umfasst 3.119.231 Nodes und 47.952.968 Relationships. enWiki ist eine durch eine Speicherauszugsdatei der englischen Wikipedia mit Graphipedia generierte Neo4j Datenbank. Sie besitzt eine physische Größe von 5,19 GB und umfasst 11.786.855 Nodes und 120.534.047 Relationships. Die Abbildung 5.1 stellt die Verteilung der Knotenränge (Degree), also die Anzahl von ein- (In, rot) oder ausgehenden (Out, blau) Kanten, für deWiki und enWiki dar. Um die Verteilungen deutlicher darstellen zu können, sind beide Achsen der Diagramme als logarithmisches Maß abgebildet. Beide Darstellungen ähneln sich dabei stark, was zu erwarten war, da sie auf dem gleichen Datenmodel (Graphipedia) basieren und die Seiten, den gleichen Kriterien durch Wikipedia, unterliegen. In der deWiki besitzen 50% aller Knoten nur drei oder weniger eingehende Kanten, 70% aller Knoten sieben oder weniger eingehende Kanten und selbst 90% aller Knoten besitzen nur 31 oder weniger eingehende Kanten. Obwohl ein Knoten mit 131.071 eingehenden Kanten (Maximum) existiert, liegt das Mittel hier dennoch bei 17 Kanten. Weiterhin besitzen 50% der Knoten in der deWiki sieben oder weniger ausgehende Kanten, 70% besitzen 31 oder weniger Kanten und 90% besitzen 63 oder weniger ausgehende Kanten. Die maximale Out-Degree liegt hier bei 8.191 und der mittlere Out-Degree liegt ebenfalls bei 17. Generell lässt diese Verteilung auf einige "wichtige" Knoten mit besonders hohem In-Degree und vielen Knoten ohne eingehende Kanten schließen, welche primär auf diese "wichtigen" Knoten linken. Die Verteilungen in der enWiki untermauern diese These. Hier besitzen 50% der Knoten sogar nur eine oder weniger ausgehende und eine eingehende Kante. 90% der Knoten besitzen 15 oder weniger eingehende und 31 ausgehende Kanten. Im Mittel sind es hier elf Kanten. Knoten haben maximal 524.287 eingehende Kanten und maximal 8.191 ausgehende Kanten. Spezifikation des Testrechners Zur Aufnahme von Messwerten wurden die folgenden drei Systeme benutzt: Testsystem A ist ein Windows-Notebook. Es besitzt einen Intel(R) Core(TM) i7-4720HQ CPU @ 2.60GHz (4 Kerne mit Hyperthreading), 16GB RAM und Windows 8.1. Die Datenbanken lagen für die Messungen stets auf einer SSD. Es wird Java 1.8.0_45 (Java(TM) SE Runtime Environment (build 1.8.0_45-b15)) genutzt. Testsystem B ist ein Linux-Rechner. Er besitzt einen Intel(R) Core(TM) i7-3960X CPU @ 3.30GHz (6 Kerne mit Hyperthreading), 32GB RAM und Ubuntu 15.04 unter GNU/Linux 3.19.0-26-generic 54 5.1 Testbedingungen Degree-Verteilung deWiki 10000000 1000000 Anzahl 100000 10000 1000 100 10 1 1 10 100 1000 10000 100000 1000000 100000 1000000 Degree Degree-Verteilung enWiki 10000000 1000000 Anzahl 100000 10000 1000 100 10 1 1 10 100 1000 10000 Degree Abbildung 5.1: Degree-Verteilungen in deWiki und enWiki (logarithmische Skalen) Rot = Out-Degree, Blau = In-Degree x86_64. Die Datenbanken lagen für die Messungen stets auf einer SSD. Es wird Java 1.7.0_79 (OpenJDK Runtime Environment (IcedTea 2.5.6)) genutzt. Testsystem C ist ein Linux-Rechner. Er besitzt vier AMD Opteron(TM) Processor 6274 @ 2.20GHz (16 Kerne mit 2 Hardwarethreads), 68GB RAM und Ubuntu 15.04 unter GNU/Linux 3.19.0-22-generic x86_64. Die Datenbanken lagen für die Messungen auf einer konventionellen Festplatte. Es wird Java 1.7.0_79 (OpenJDK Runtime Environment (IcedTea 2.5.5)) genutzt. 55 Kapitel 5 Messungen 5.2 VERGLEICH DER APIS Um die unterschiedliche Leistungsfähigkeit der in Abschnitt 2.3.2 vorgestellten Java APIs zu demonstrieren, wurde Random Walk (RW) für die Core API und für die Kernel API entwickelt. Wie in Abbildung 2.4 ersichtlich, baut die Traverser API auf der Core API auf und kann somit nur maximal so performant wie die Core API selbst sein. Aus diesem Grund wurde davon abgesehen die Traverser API zusätzlich zu testen. Die mittleren Laufzeiten von 10 Ausführungen pro Threadanzahl beider Implementierungen (Core API RW und Kernel API RW) wurden unter den gleichen physischen Bedingungen auf den Testsystemen A und B sowie mit den selben Parametern aufgenommen. Daraus wurde der Speedup der Kernel API ermittelt und in Abbildung 5.2 dargestellt. Testsystem A 1.6 1.5 Speedup 1.4 1.3 1M 10M 100M 1.2 1.1 1 0.9 1 3 6 12 24 48 72 96 120 Threads Testsystem B 1.5 1.4 Speedup 1.3 1.2 1M 10M 100M 1.1 1 0.9 1 3 6 12 24 48 72 96 120 Threads Abbildung 5.2: Speedup der Kernel API gegenüber der Core API, ermittelt aus den mittleren Laufzeiten von Random Walk in Abhängigkeit von Threadanzahl mit 1 Millonen, 10 Millionen und 100 Millionen Operationen als NUMBER_OF_STEPS 56 5.2 Vergleich der APIs Offensichtlich besitzt der allergrößte Teil der Messungen einen Speedup der größer als eins ist. Dies zeigt, dass die Kernel API allgemein schneller arbeitet als die Core API. Im Maximum ist sie ca. 1,5 mal bzw. ca. 1,45 mal schneller. Aus diesem Grund wurden die nachfolgenden Algorithmen mit der schnelleren Kernel API implementiert. Man sieht ebenfalls, dass sich der Speedup bei den meisten Messungen in Abhängigkeit von der Operationsanzahl verändert. Bis auf wenige Ausnahmen scheint dabei zu gelten, dass der Speedup maximal so groß ist wie der Speedup bei gleicher Threadanzahl mit weniger Operationen. Daraus kann man schließen, dass die Kernel API effizienter mit (vielen) Threads, die nur kleinere Transaktionen durchführen, arbeitet. 57 Kapitel 5 Messungen 5.3 RANDOM WALK In diesem Abschnitt werden die Messwerte der im Abschnitt 3.1 beschriebenen Algorithmen für Random Walk vorgestellt. 5.3.1 Auswertung Speedup RW auf deWiki mit 10M Operationen 120 mittlere Laufzeit [s] 100 80 60 40 20 0 ST 1 3 6 12 24 48 72 96 120 Threads Speedup gegenüber Single Thread RW 8 7 Speedup 6 5 4 3 2 1 0 1 3 6 12 24 48 72 96 120 Threads Abbildung 5.3: Übersicht der mittleren Laufzeiten mit Standardabweichung von RW mit 10M Operationen auf deWiki und dem resultierenden Speedup gegenüber SingleThread RW Blau = System A, Rot = System B, Grün = System C Die Abbildungen 5.3 und 5.4 stellen jeweils auf der linken Seite die gemessene mittlere Laufzeit von Random Walk inkl. Standardabweichung dar. Auf der rechten Seite der Abbildungen befindet sich der daraus ermittelte Speedup gegenüber Single Thread RW. Die Messungen wurden mit variierender Anzahl an Threads durchgeführt. Dabei wurden die Messungen stets auf 58 5.3 Random Walk RW auf enWiki mit 10M Operationen 120 mittlere Laufzeit [s] 100 80 60 40 20 0 1 3 6 12 24 48 72 96 120 128 144 160 176 192 208 224 240 256 Threads Speedup gegenüber Single Thread Algorithmus 12 10 Speedup 8 6 4 2 0 1 3 6 12 24 48 72 96 120 128 144 160 176 192 208 224 240 256 Threads Abbildung 5.4: Übersicht der mittleren Laufzeiten mit Standardabweichung von RW mit 10M Operationen auf enWiki und dem resultierenden Speedup gegenüber SingleThread RW Blau = System A, Rot = System B, Grün = System C beiden Datenbanken und allen drei Testsystemen gemacht und jeweils der Mittelwert aus zehn Durchläufen gebildet. Vergleicht man beide Abbildungen, so wird schnell ersichtlich, dass es keinen signifikanten Unterschied zwischen den jeweiligen Laufzeiten pro Thread auf beiden Datenbanken gibt. Das belegt die intuitiven Vermutung, dass Random Walk nur abhängig von der Anzahl der Operationen ist (siehe 5.2), nicht aber von der physischen Datenbankgröße. Weiterhin scheinen die Werte mit steigender Anzahl der Threads bis zu einem gewissen Punkt zu skalieren. Für das Testsystem A beispielsweise scheint der Speedup jeweils zu einem Maxi- 59 Kapitel 5 Messungen malwert bei ca. 5,5 zu konvergieren. Für System B liegt dieser Wert etwa bei 8 und für System C bei etwas über 10. Diese Unterschiede liegen natürlich in den unterschiedlichen Hardwareausstattungen der Testsysteme begründet. Obwohl die Systeme A und B jeweils nur in der Lage sind 8 bzw. 12 Hardwarethreads gleichzeitig auszuführen, zeigen die Abbildungen, dass der Einsatz von mehr Threads sich positiv auf die Laufzeiten auswirkt. Oftmals kann dieses Verhalten damit begründet werden, dass die Threads auf IO-Operationen der Festplatte warten müssen und dabei vom Prozessor ausgetauscht werden. Dies können wir aber ausschließen, da nach dem WarmupSchritt keine weiteren lesenden Plattenzugriffe stattfinden. Daher ist es wahrscheinlich, dass die Threads während des Wartens auf die Kernel API, den Cache oder den Hauptspeicher vom Prozessor vorsorglich ausgetauscht werden. 5.3.2 Auswertung Ressourcenauslastung Single Thread Random Walk 8 Thread Random Walk Abbildung 5.5: CPU-Nutzung von Random Walk mit einem Thread (oben) und acht Threads (unten) mit Testsystem A auf enWiki. Ermittelt mit jVisualVM Grün Markierter Bereich = Warmup (ca.) 60 5.3 Random Walk Single Thread Random Walk 8 Thread Random Walk Abbildung 5.6: Speichernutzung von Random Walk mit einem Thread (oben) und acht Threads (unten) mit Testsystem A auf enWiki. Ermittelt mit jVisualVM Grün markierter Bereich = Warmup (ca.) Zur Demonstration der unterschiedlichen Resourcenauslastung von CPU und Arbeitsspeicher wurde Random Walk auf enWiki mit einem Thread und beispielhaft mit acht1 Threads auf Testsystem A durchgeführt. Die Ausführung wurde mit jVisualVM visualisiert und ist in Abbildung 5.5 für den CPU-Verbrauch und in Abbildung 5.6 für die Speichernutzung dargestellt. Um die Phasen des Programms besser unterscheiden zu können, wurde der ungefähre Warmup-Bereich grün hervorgehoben. Mit acht Threads erkennt man eine Ausnutzung des vorhandenen Prozessors, die während des Algorithmus (nach dem Warmup) größtenteils über 90% liegt. Im Vergleich dazu erreicht die Single Thread Variante maximal nur knapp 38% und im Schnitt sogar nur ca. 20% CPU-Nutzung. Random Walk mit mehreren Threads macht demnach besseren Gebrauch von der vorhandenen CPU-Infrastruktur, was sich, wie bereits in 5.3.1 gezeigt, auch im Speedup bemerkbar macht. Was die Speichernutzung in Abbildung 5.6 angeht, so finden sich kaum Unterschiede zwischen beiden Messungen. Beide sichern sich annähernd gleich viel Heap-Speicher und nutzen ihn ähnlich. Die tatsächliche Nutzung des Heap-Speichers schwankt im Single Thread Durchgang etwas 1 Zur Erinnerung: Acht Threads entsprechen auf Testsystem A der Anzahl von verfügbaren Hardwarethreads 61 Kapitel 5 Messungen mehr (Zacken). Dies ist durch die sich auf- und abbauenden Lese-Transaktionen mit der Datenbank begründet. In der Durchführung mit acht Threads überlagern sich das Auf- und Abbauen der Transaktionen, wodurch die Zacken nicht so deutlich hervortreten. NUMBER_OF_STEPS 1M 10 M 100 M deWiki 5,4 % 19,5 % 74,2 % enWiki 0,8 % 6,5 % 36,1 % Abbildung 5.7: Prozentsatz an besuchten Knoten je Datenbank für unterschiedliche NUMBER_OF_STEPS Eine durchgeführte Memory Access Analyse mit Intel VTune Amplifier XE 2016 weißt darauf hin, dass Random Walk in beiden Konfigurationen ca. 22% der Laufzeit durch die Hauptspeicherlatenz begrenzt wird. Da viele Daten angefordert und gelesen werden müssen und der Prozessor somit darauf warten muss. Die Single Thread Variante erwies sich außerdem als unvorteilhaft in der Nutzung der Caches, denn 18% der CPU Takte wird auf fehlschlagende Suchen in den Cache-Levels gewartet. Dieses Verhalten folgt allerdings aus dem für die Datenbank zufälligen Zugriffsverhalten von Random Walk, welches allgemein auch unvorteilhaft für die Kernel API ist, die sequenzielles Lesen von Daten besser unterstützt. Die Tabelle in Abbildung 5.7 gibt anhand aufgenommener Besuchsstatistiken je einer Random Walk Ausführung einen Eindruck von vom eher zufälligen und ungleichmäßigen Zugriffsmuster des Random Walk Algorithmus. Ebenfalls auffällig ist, dass Random Walk in der acht Thread Variante zu 14% erfolgreich auf den Level-1 Cache warten muss. Dies ist bedingt aus den LOAD- und STORE-Operationen, der anderen Threads, die parallel ebenfalls diesen Kern (und somit den Cache) benutzen. 62 5.4 Experimentelle Ermittelung des Parameters BATCHSIZE 5.4 EXPERIMENTELLE ERMITTELUNG DES PARAMETERS BATCHSIZE Um für den wichtigen Parameter BATCHSIZE (siehe 4.2) einen möglichst optimalen Wert zu erhalten, wurden Messungen anhand von parallelem WCC und SCC auf deWiki durchgeführt. Abbildung 5.8 zeigt das Ergebnis dieser Laufzeitmessungen mit acht Threads auf Testsystem A, mit zwölf Threads auf Testsystem B und mit 128 Threads auf Testsystem C1 . Pro gemessener BATCHSIZE wurden jeweils 5 Laufzeiten aufgenommen, deren Mittelwert nun dargestellt ist. Wie man erkennen kann, hat die Wahl des Parameterwertes einen Einfluss auf die Ausführungszeiten. Während WCC auf allen drei Systemen in etwa (bis auf wenige Ausnahmen) stabile Laufzeiten aufweist, zeigt sich der Einfluss der BATCHSIZE besonders bei SCC. Dem Experiment folgend, liegen die optimalen Werte dieses Parameters für das Testsystem [A] bei 1800, für das Testsystem B bei 600 und für das Testsystem C bei 200. Ähnlich der Ergebnisse in Abschnitt 5.2 erzielen Threads mit weniger Operationen in den Transaktionen - also mit tendenziell kleinerer BATCHSIZE - bessere Laufzeiten. Man kann also schließen, dass der Parameter in Abhängigkeit des verwendeten Prozessors gewählt werden muss. Zudem skaliert der optimale Wert mit der Anzahl der Kerne. Wählt man den Wert des Parameters zu groß, so schöpft man nicht das volle Potential der Parallelität aus und Threads müssen an Synchronisationspunkten unnötig lange aufeinander warten, weil Abschnitte quasi sequenziell ausgeführt werden. Ein zu kleiner Wert hingegen verlängert auf den Systemen A und B die Laufzeiten. Dies lässt sich auf den Mehraufwand des ständigen Startens und Verwaltens von neuen Threads zurückführen. Für die weiteren Messungen von SCC und WCC wurden die BATCHSIZE-Parameter auf die oben erwähnten Werte je System gesetzt. Allerdings garantiert dies aufgrund des verwendeten Messrasters nur eine nahezu optimale Laufzeit auf deWiki. Der Einsatz von mehr oder weniger Threads muss demnach nicht zwangsläufig optimale Laufzeitwerte erreichen. Eine analoge ParameterBestimmung auf enWiki wäre ebenfalls möglich und würde ggf. bessere Werte liefern. Aus räumlichen Gründen wurde aber davon abgesehen. 1 Spezifikationen siehe Abschnitt 5.1 63 Kapitel 5 Messungen 80 75 70 Laufzeit [s] 65 60 55 SCC_A WCC_A 50 45 40 35 30 0 2000 4000 6000 BATCHSIZE 8000 10000 70 65 Laufzeit [s] 60 55 50 SCC_B WCC_B 45 40 35 30 0 2000 4000 6000 BATCHSIZE 8000 10000 190 180 170 Laufzeit [s] 160 150 SCC_C WCC_C 140 130 120 110 100 0 2000 4000 6000 BATCHSIZE 8000 10000 Abbildung 5.8: Einfluss des BATCHSIZE Parameters auf die mittlere Laufzeit von WCC und SCC auf den verschiedenen Testsystemen A, B und C (von oben nach unten) 64 5.5 Weakly Connected Components 5.5 WEAKLY CONNECTED COMPONENTS In diesem Abschnitt werden die Messwerte der im Abschnitt 3.2 beschriebenen Algorithmen für Weakly Connected Components vorgestellt. 5.5.1 Auswertung Speedup 1.4 WCC auf Testsystem A 70 1.2 60 Speedup mittlere Laufzeit [s] 1 50 40 30 0.8 0.6 0.4 20 0.2 10 0 0 ST 1 2 4 8 12 16 ST 32 1 2 4 12 16 32 48 96 1.6 WCC auf Testsystem B 80 1.4 70 1.2 60 Speedup mittlere Laufzeit [s] 8 Threads Threads 50 40 30 1 0.8 0.6 0.4 20 0.2 10 0 0 ST 1 3 6 12 24 48 ST 96 1 3 6 12 24 Threads Threads 1.2 WCC auf Testsytem C 250 1 0.8 Speedup mittlere Laufzeit [s] 200 150 0.6 100 0.4 50 0.2 0 0 ST 1 3 6 12 Threads 24 48 96 128 ST 1 3 6 12 24 48 96 128 Threads Abbildung 5.9: Übersicht der mittleren Laufzeiten mit Standardabweichung von WCC auf deWiki und dem resultierenden Speedup gegenüber SingleThread WCC Die Abbildungen 5.9 und 5.10 stellen jeweils auf der linken Seite die gemessene mittlere Laufzeit von Weakly Connected Components inkl. Standardabweichung dar. Die rechte Seite der Abbildungen illustriert den aus den Laufzeiten resultierenden Speedup gegenüber Single Thread WCC. Die Messungen wurden mit variierender Anzahl an Threads durchgeführt. Dabei wurden 65 Kapitel 5 Messungen 1.2 WCC auf Testsystem A 350 1 0.8 250 Speedup mittlere Laufzeit [s] 300 200 150 0.6 0.4 100 0.2 50 0 0 ST 1 2 4 8 12 16 ST 32 1 2 4 8 12 16 32 Threads Threads 1.2 WCC auf Testsystem B 400 1 300 0.8 Speedup mittlere Laufzeit [s] 350 250 200 150 0.6 0.4 100 0.2 50 0 0 ST 1 3 6 12 24 48 ST 96 1 3 6 12 24 48 96 Threads Threads 1.2 SCC auf Testsystem C 7000 1 0.8 5000 Speedup mittlere Laufzeit [s] 6000 4000 3000 0.6 0.4 2000 0.2 1000 0 0 ST 1 3 6 12 24 Threads 48 64 96 128 ST 1 3 6 12 24 48 96 128 Threads Abbildung 5.10: Übersicht der mittleren Laufzeiten mit Standardabweichung von WCC auf enWiki und dem resultierenden Speedup gegenüber SingleThread WCC die Messungen stets auf beiden Datenbanken und allen drei Testsystemen gemacht und jeweils der Mittelwert aus zehn Durchläufen gebildet. Als BATCHSIZE wurden die ermittelten Werte aus Abschnitt 5.4 gewählt, die ggf. nicht für enWiki optimal sein könnten. Die Laufzeiten und somit der Speedup von WCC verhält sich auf den beiden Datenbanken höchst unterschiedlich. Auf der deWiki lässt sich für alle Systeme beobachten, dass der Single Thread Algorithmus, der einfaches BFS benutzt, gegenüber dem Algorithmus mit parallelem BFS mit einem Thread, deutlich schneller ist. Dies wird dem Verwaltungsmehraufwand des parallelen BFS geschuldet sein. Mit acht Threads erreicht System A seinen maximalen Speedup bei ca. 1,2. System B erreicht einen Speedup von ca. 1,4 mit 48 Threads und System C schafft es mit 24 Threads knapp über den Speedup von 1. Betrachtet man die Messwerte auf enWiki, so zeichnet sich ein noch drastischeres Bild. Hier erreicht keine Messung des parallelen Algorithmus die Laufzeiten der sequenziellen Variante. In den besten Fällen ist der parallele Algorithmus nur 20% langsamer. 66 5.5 Weakly Connected Components Wie es scheint, verbraucht der Mehraufwand des ständigen Thread starten, verwalten und der Synchronisierung zwischen den Ebenen (besonders auf größeren Daten) den kompletten Laufzeitgewinn aus der Parallelisierung der Knotenexpansion. 5.5.2 Auswertung Ressourcenauslastung Single Thread Weakly Connected Components (nutzt BFS) 8 Thread Weakly Connected Components (nutzt parallel_BFS) Abbildung 5.11: CPU-Nutzung von WCC mit einem Thread (oben) und acht Threads (unten) mit Testsystem A auf deWiki. Ermittelt mit jVisualVM Grün Markierter Bereich = Warmup (ca.) Zur Demonstration der unterschiedlichen Ressourcenauslastung von CPU und Arbeitsspeicher wurde Weakly Connected Components auf deWiki mit einem Thread (BFS) und beispielhaft mit acht Threads (paralleles BFS) auf Testsystem A durchgeführt. Die Ausführung wurde mit jVisualVM visualisiert und ist in Abbildung 5.5 für den CPU-Verbrauch und in Abbildung 5.6 für die Speichernutzung dargestellt. Um die Phasen des Programms besser unterscheiden zu können, wurde der ungefähre Warmup-Bereich grün hervorgehoben. 67 Kapitel 5 Messungen Single Thread Weakly Connected Components (nutzt BFS) 8 Thread Weakly Connected Components (nutzt parallel_BFS) Abbildung 5.12: Speichernutzung von WCC mit einem Thread (oben) und acht Threads (unten) mit Testsystem A auf deWiki. Ermittelt mit jVisualVM Grün markierter Bereich = Warmup (ca.) Während die CPU-Nutzung in der parallelen Version die längste Zeit bei über 80% liegt, scheint die Single Thread Variante von WCC die CPU nur punktuell bis maximal 50% ausnutzen zu können und verwendet ansonsten die meiste Zeit nur ca. 15%. Ebenfalls auffällig ist, dass die Single Thread Variante an den Punkten größerer CPU-Nutzung mehr Garbage Collection erzeugt. Was die Speichernutzung in Abbildung 5.12 angeht, so sieht man dass acht Thread WCC deutlich mehr Speicher reserviert. Dies liegt im Mehraufwand des Threadstartens begründet, bei dem immer jedem Thread ein Stück lokaler Speicher reserviert und zugewiesen werden muss. Eine durchgeführte Memory Access Analyse mit Intel VTune Amplifier XE 2016 weißt darauf hin, dass beide getesteten Varianten von WCC durch den Hauptspeicher begrenzt werden. 28% der CPU Zyklen wartet der BFS-basierte Algorithmus auf den Speicher. Dabei wird zu 24% auf fehlschlagende Anfragen an die Level 1-3 Caches gewartet. Die parallele Version wartet sogar zu 43.8% der Zeit auf den Speicher und 10% aller Suchen im Cache schlagen fehl. Die schlechte Cache-Nutzung mag im (für die Datenbank zufälligen) Zugriffsmuster von BFS begründet liegen. 68 5.6 Strongly Connected Components 5.6 STRONGLY CONNECTED COMPONENTS In diesem Abschnitt werden die Messwerte der im Abschnitt 3.3 beschriebenen Algorithmen für Strongly Connected Components vorgestellt. 5.6.1 Auswertung Speedup 2 SCC auf Testsystem A 1.8 120 1.6 1.4 80 Speedup mittlere Laufzeit [s] 100 60 1.2 1 0.8 0.6 40 0.4 20 0.2 0 0 ST 1 2 4 8 12 16 ST 32 1 2 4 8 12 16 32 24 48 96 Threads Threads 3 SCC auf Testsystem B 140 2.5 2 100 Speedup mittlere Laufzeit [s] 120 80 60 1.5 1 40 0.5 20 0 0 ST 1 3 6 12 24 48 ST 96 1 3 6 12 Threads Threads 3 SCC auf Testsystem C 400 2.5 300 2 Speedup mittlere Laufzeit [s] 350 250 200 150 1.5 1 100 0.5 50 0 0 ST 1 3 6 12 Threads 24 48 96 128 ST 1 3 6 12 24 48 96 128 Threads Abbildung 5.13: Übersicht der mittleren Laufzeiten mit Standardabweichung von SCC auf deWiki und dem resultierenden Speedup gegenüber SingleThread SCC Auf der linken Seite der Abbildungen 5.13 und 5.14 sind die jeweils gemessene mittlere Laufzeit von Strongly Connected Components inkl. Standardabweichung dargestellt. Der sich aus den Laufzeiten ergebene Speedup gegenüber TARJANs Algorithmus (ST) ist auf der rechten Seite der Abbildungen illustriert. Die Messungen wurden mit variierender Anzahl an Threads durch- 69 Kapitel 5 Messungen 7 SCC auf Testsystem A 1600 6 5 1200 Speedup mittlere Laufzeit [s] 1400 1000 800 600 4 3 2 400 1 200 0 0 ST 1 2 4 8 12 16 ST 32 1 2 4 12 16 32 48 96 14 SCC auf Testsystem B 2000 12 1800 1600 10 1400 Speedup mittlere Laufzeit [s] 8 Threads Threads 1200 1000 8 6 800 4 600 400 2 200 0 0 ST 1 3 6 12 24 48 ST 96 1 3 6 12 24 Threads Threads 12 SCC auf Testsystem C 7000 10 8 5000 Speedup mittlere Laufzeit [s] 6000 4000 3000 6 4 2000 2 1000 0 0 ST 1 3 6 12 24 Threads 48 64 96 128 ST 1 3 6 12 24 48 64 96 128 Threads Abbildung 5.14: Übersicht der mittleren Laufzeiten mit Standardabweichung von SCC auf enWiki und dem resultierenden Speedup gegenüber SingleThread SCC geführt. Dabei wurden die Messungen stets auf beiden Datenbanken und allen drei Testsystemen gemacht und es wurde jeweils der Mittelwert aus zehn Durchläufen gebildet. Als BATCHSIZE wurden die ermittelten Werte aus Abschnitt 5.4 gewählt, die ggf. nicht für enWiki optimal sein könnten. Vergleicht man die Laufzeiten zwischen deWiki und enWiki auf allen Systemen so erkennt man, dass die Laufzeit von der Größe der verwendeten Datenbank abhängig ist. Dies entspricht der Komplexitätsabschätzung aus 3.3, in der die Laufzeit von SSC in Abhängigkeit der Knoten- und Kantenmengen gesetzt wurde. In jedem der gemessenen Fälle ist der Einsatz von Multistep mit nur einem Thread circa viermal schneller als TARJANs Algorithmus. Die minimale Laufzeit auf deWiki und damit den größten Speedup erreicht System A mit acht Threads (Speedup ca. 1,9), System B mit zwölf Threads (Spee- 70 5.6 Strongly Connected Components dup ca. 2,75) und System C mit zwölf Threads (Speedup ca. 2,5). Auf der größeren enWiki zeigt sich die Wirkung des Strongly Connected Components Algorithmus sogar noch deutlicher. Der größte Speedup wird hier von System A mit vier Threads erzielt (ca. 6,2). System B erreicht mit sechs Threads einen Speedup von zwölf und System C mit 24 Threads einen Speedup von 12,4. Da die optimalen Threadzahlen für System A und B jeweils der Anzahl ihrer logischen (deWiki) oder physischen (enWiki) Kerne entsprechen, liegt es nahe, darin die Begründung für die Optimalität zu sehen. System C könnte davon ausgenommen sein, weil die benötigte Kommunikation zwischen den vier 16-Kern Prozessoren einen Mehraufwand bedeutet, der sich auf die Laufzeiten auswirkt. Zusätzlich führt die Nutzung von mehreren Prozessoren hier womöglich dazu, dass vorhandene Level 1-3 Caches schlechter genutzt werden und darum öfter auf den langsameren Hauptspeicher zugegriffen werden muss. Um den Einfluss der parallelen Breitensuche auf die Laufzeiten des Multistep-Algorithmus nachzuweisen, wurden Messungen von acht Thread SCC (Multistep) auf Testsystem A mit deWiki und enWiki durchgeführt. Dabei wurden 10 Durchläufe mit einfacher Breitensuche und weitere zehn mit paralleler Breitensuche durchgeführt. Die Abbildung 5.15 stellt diese Ergebnisse im Vergleich dar. Durch den Einsatz der parallelen Breitensuche verringerte sich in beiden Fällen die Laufzeit von SCC. Bei deWiki handelt es sich dabei um über 30% und bei enWiki auch noch um über 15%. Da die Breitensuche in Multistep nur im Forward-Backward Sweep einmalig zum Einsatz kommt, lässt sich (zusammen mit den Ergebnissen aus 5.5) feststellen, dass sich die parallele Breitensuche in ihrer implementierten Form, eher für große Komponenten eignet, so dass der Mehraufwand des Threadverwaltens nicht den eigentlichen Arbeitsaufwand überwiegt. 1.35 1.3 300 1.25 250 200 BFS parallele BFS 150 100 Speedup mittlere Laufzeit von SCC [s] 350 1.2 1.15 BFS parallele BFS 1.1 1.05 1 50 0.95 0 0.9 deWiki enWiki deWiki enWiki Abbildung 5.15: Übersicht der mittleren Laufzeiten von SCC bei Nutzung von BFS oder paralleler BFS auf deWiki und enWiki und dem resultierenden Speedup gegenüber BFS 5.6.2 Auswertung Ressourcenauslastung Zur Demonstration der unterschiedlichen Ressourcenauslastung von CPU und Arbeitsspeicher wurde Strongly Connected Components auf deWiki mit einem Thread (TARJAN) und beispielhaft mit acht Threads (Multistep) auf Testsystem A durchgeführt. Die Ausführung wurde mit jVisualVM visualisiert und ist in Abbildung 5.5 für den CPU-Verbrauch und in Abbildung 5.6 für die Speichernutzung dargestellt. Um die Phasen des Programms besser unterscheiden zu können, wurde der ungefähre Warmup-Bereich grün hervorgehoben. Die CPU-Nutzung von TARJANs Algorithmus scheint nach Abbildung 5.16 primär von der Such- 71 Kapitel 5 Messungen Single Thread Strongly Connected Components (TARJAN) 8 Thread Strongly Connected Components Abbildung 5.16: CPU-Nutzung von SCC mit einem Thread (oben) und acht Threads (unten) mit Testsystem A auf deWiki. Ermittelt mit jVisualVM Grün Markierter Bereich = Warmup (ca.) tiefe der aktuellen Komponente abzuhängen. Große Komponenten führen zu vielen rekursiven Aufrufen, die aber ebenso viel Garbage Collection nach sich ziehen. Genau dieses Verhalten lässt sich im oberen Bild nachvollziehen. Nach der ersten, intensiveren Untersuchung der größeren Komponenten folgen alle kleineren Komponenten, die sequenziell abgearbeitet werden. Problematisch ist dabei, dass die CPU für diese längste Zeit nur zu ca. 10% ausnutzt wird. Im Gegensatz zu TARJANs Algorithmus, nutzt (acht Thread) Multistep die CPU deutlich effizienter, was sich auch im Speedup (siehe 5.6.1) zeigt. Besonders der MS-Coloring Schritt ist in der Abbildung (Mitte) erkennbar und erreicht oftmals Auslastungswerte über 90%. Was die Speichernutzung in Abbildung 5.17 angeht, so nutzt acht Thread Multistep etwa 10% mehr Speicher. Im Gegensatz dafür lässt sich TARJANs Algorithmus allerdings auf Graphen mit größeren starken Zusammenhangskomponenten nur bedingt durchführen. Dafür muss die Größe des Stacks pro Thread der Java Virtuellen Maschine manuell auf einen entsprechend größeren Wert 72 5.6 Strongly Connected Components Single Thread Strongly Connected Components (TARJAN) 8 Thread Strongly Connected Components Abbildung 5.17: Speichernutzung von SCC mit einem Thread (oben) und acht Threads (unten) mit Testsystem A auf deWiki. Ermittelt mit jVisualVM Grün markierter Bereich = Warmup (ca.) gesetzt werden. Das ist durch die rekursiven, Funktionsaufrufe von TARJAN bedingt. Eine durchgeführte Memory Access Analyse mit Intel VTune Amplifier XE 2016 weißt darauf hin, dass Multistep 41% der Laufzeit durch die Hauptspeicherlatenz begrenzt wird, da viele Daten angefordert und gelesen werden müssen und der Prozessor darauf warten muss. Im Fall von TARJANs Algorithmus führt die Analyse an, dass die Caches des Prozessors nicht vorteilhaft genutzt werden. Es muss sehr oft (11,3% der Laufzeit) darauf gewartet werden, dass alle Caches mitteilen, dass sie ein Datum nicht verfügbar haben. Erst danach wird der deutlich langsamere Arbeitsspeicher angefragt. 73 Kapitel 5 Messungen 74 6 ZUSAMMENFASSUNG UND AUSBLICK 6.1 ZUSAMMENFASSUNG Das Ziel der Arbeit war es, ein Konzept zu entwickeln und zu demonstrieren, mit dem (parallele) Analysealgorithmen in Neo4j als Erweiterungen des Funktionsumfangs implementiert werden können. Das in Kapitel 4 vorgestellte Konzept einer Extension des Neo4j-Servers und seine Realisierung mit den drei implementierten Algorithmen Random Walk, Weakly Connected Components und Strongly Connected Components erfüllen diese Anforderungen. Um die effizienteste API von Neo4j für die Implementierung zu verwenden wurde ein Vergleich zwischen Neo4js Core und Kernel API anhand von Random Walk durchgeführt. Die Ergebnisse aus Abschnitt 5.2 zeigen, dass die Kernel API dabei stets zu bevorzugen ist. Alle vorgestellten Algorithmen wurden in der sequenziellen und parallelen Version deshalb auf Grundlage der Kernel API realisiert. Die Ergebnisse der Messungen der Algorithmen belegen, dass die Anwendung von parallelen Analysealgorithmen in Neo4j gegenüber ihren sequenziellen Versionen eine bessere Nutzung der vorhandenen Hardwareressourcen darstellt. Random Walk konnte gegenüber einer sequenziellen Ausführung bis ca. 11-mal schneller ausgeführt werden. Strongly Connected Components sogar circa 12-mal schneller. Die parallele Implementierung von Weakly Connected Components erreichte ausschließlich auf der kleineren Datenbasis einen Speedup von bis zu 1,4. Der implementierten, naiven parallelen Breitensuche konnte aber in diesem Projekt nachgewiesen werden, dass sie sich für Suchen auf großen Komponenten mit besonders vielen involvierten Knoten und Suchebenen eignet. Diese kommen vor allem im parallelen Strongly Connected Components Algorithmus vor, während die Arbeit von Weakly Connected Components auf den getesteten Graphen primär von kleinen Komponenten geprägt ist. Bis auf Random Walk wird die optimale Laufzeit bei den implementierten Algorithmen erreicht, wenn die genutzte Threadanzahl der Anzahl an physischen Kernen des Prozessors oder der Anzahl an Hardwarethreads des Prozessors entspricht. 75 Kapitel 6 Zusammenfassung und Ausblick 6.2 AUSBLICK Da das Konzept des Projektes unabhängig von den implementierten Algorithmen aufgestellt wurde, lässt sich das Projekt mit wenig Aufwand, um weitere Algorithmen erweitern. Die bereits implementierten Algorithmen könnten mit dem Fokus auf eine noch bessere Cache-Nutzung optimiert werden, um noch bessere Laufzeiten zu erhalten. Ein weiterer Optimierungsansatz ist zu versuchen die Algorithmen so zu verändern, dass Threads auf festen Speicherbereichen arbeiten. So könnten sich Synchronisierungspunkte, fehlerhafte Suchen im Cache und Hauptspeicherzugriffe minimieren lassen. Weiterhin lässt sich der Trim-Schritt aus SCC ebenfalls leicht parallelisieren. Da dieser Schritt meist weniger als 1% der Laufzeiten ausmachte, wurde im Rahmen dieser Arbeit davon abgesehen. Dennoch könnte sich ein paralleles Trim bei sehr viel größeren Datenmengen gewinnbringend auf die Laufzeiten auswirken. In den wenigsten Fällen werden Graphdatenbanken aus nur einer Art von Knoten bestehen, wie es bei den getesteten Systemen der Fall war. Darum könnte man die Algorithmen leicht um die Option erweitern, dass sie nur auf Knoten und Kanten mit einem oder mehreren bestimmten Labels arbeiten. Die Arbeit auf diesen Sub-Graphen wird die Laufzeiten der Algorithmen weiter verringern, neue Informationen über den Sub-Graphen bereitstellen und ggf. ansonsten anfallende unnötige Daten wie Properties an Knoten, die sie nicht brauchen, vermeiden. Nachdem in dieser Arbeit das Potential von Analysen in Neo4j gezeigt wurde, könnte man nun evaluieren, ob, wann und in welchen Kontexten man externe Graph-Processing Infrastrukturen wie Giraph oder GraphX gegenüber Analysen in Neo4j bevorzugen sollte. Weiterhin könnte man Neo4js Anfragesprache Cypher so erweitern, dass man Funktionalitäten aus Extensions via Cypher abrufen und ausführen könnte. Dies wäre vergleichbar mit dem Konzept von Stored Procedures in relationalen Datenbanksystemen (wie z.B. DB2). Stored Procedures für Neo4j sind nach [51] für die Version 3.0 in Planung. 76 Literaturverzeichnis LITERATURVERZEICHNIS [1] http://www.vertriebszeitung.de/wie-ihr-vertrieb-von-guten-kundendaten-profitiert/ Abrufdatum: 24.07.2016 [2] http://www.w3schools.com/sql/sql_create_index.asp Abrufdatum: 15.01.2016 [3] https://de.wikibooks.org/wiki/Einf%C3%BChrung_in_SQL:_Trigger Abrufdatum: 15.01.2016 [4] http://www.w3schools.com/sql/sql_constraints.asp Abrufdatum: 15.01.2016 [5] http://neo4j.com/developer/graph-db-vs-rdbms/ Abrufdatum: 16.12.2015 [6] https://de.wikipedia.org/wiki/Joinalgorithmen#Nested_Loop_Join Abrufdatum: 17.01.2016 [7] https://de.wikipedia.org/wiki/NoSQL Abrufdatum: 16.01.2015 [8] http://neo4j.com/developer/graph-database/ Abrufdatum: 16.12.2015 [9] http://orientdb.com/ Abrufdatum: 17.01.2016 [10] http://neo4j.com/ Abrufdatum: 17.01.2016 [11] http://orientdb.com/why-orientdb/ Abrufdatum: 17.01.2016 [12] Ian Robinson, Jim Webber & Emil Eifrem: Graph Databases, O’REILLY, S. 158 (2015) [13] Ian Robinson, Jim Webber & Emil Eifrem: Graph Databases, O’REILLY, S. 166-170 (2015) 77 Literaturverzeichnis [14] http://db-engines.com/de/ranking/graph+dbms Abrufdatum: 20.01.2016 [15] https://github.com/neo4j/neo4j Abrufdatum: 20.01.2016 [16] http://neo4j.com/editions/ Abrufdatum: 20.01.2016 [17] https://de.wikipedia.org/wiki/ACID Abrufdatum: 23.01.2016 [18] http://neo4j.com/docs/stable/cypher-query-lang.html Abrufdatum: 20.01.2016 [19] http://neo4j.com/docs/stable/javadocs/org/neo4j/graphdb/package-summary.html Abrufdatum: 21.01.2016 [20] http://neo4j.com/docs/stable/javadocs/org/neo4j/graphdb/PropertyContainer.html Abrufdatum: 23.01.2016 [21] http://neo4j.com/docs/stable/javadocs/org/neo4j/graphdb/Node.html Abrufdatum: 23.01.2016 [22] http://neo4j.com/docs/stable/tutorial-traversal-java-api.html Abrufdatum: 23.01.2016 [23] http://neo4j.com/docs/stable/javadocs/org/neo4j/graphdb/Label.html Abrufdatum: 23.01.2016 [24] http://neo4j.com/docs/stable/javadocs/org/neo4j/graphdb/Relationship.html Abrufdatum: 23.01.2016 [25] http://neo4j.com/docs/stable/javadocs/org/neo4j/graphdb/Path.html Abrufdatum: 23.01.2016 [26] http://neo4j.com/docs/stable/javadocs/org/neo4j/graphdb/GraphDatabaseService.html Abrufdatum: 23.01.2016 [27] https://github.com/neo4j/neo4j/tree/2.3.1/community/kernel Abrufdatum: 23.01.2016 [28] http://neo4j.com/docs/stable/server-plugins.html Abrufdatum: 21.01.2016 [29] http://neo4j.com/docs/stable/server-unmanaged-extensions.html Abrufdatum: 21.01.2016 [30] http://www.graphanalysis.org/ Abrufdatum: 04.02.2016 [31] http://neo4j.com/customers/ Abrufdatum: 04.02.2016 [32] http://giraph.apache.org/ Abrufdatum: 04.02.2016 78 Literaturverzeichnis [33] http://spark.apache.org/graphx/ Abrufdatum: 04.02.2016 [34] Urs Gleim, Tobias Schüle: Multicore Software, dpunkt.verlag, S. 3-21 (2012) [35] https://de.wikipedia.org/wiki/Pipeline_(Prozessor) Abrufdatum: 31.01.2016 [36] Urs Gleim, Tobias Schüle: Multicore Software, dpunkt.verlag, S. 287-291 (2012) [37] John L. Gustafson: Reevaluating Amdahl’s law. Commun. ACM 31, 5 (May 1988), 532-533. [38] https://en.wikipedia.org/wiki/PageRank#Distributed_algorithm_for_PageRank_computation Abrufdatum: 20.12.2015 [39] https://de.wikipedia.org/wiki/Breitensuche#Eigenschaften Abrufdatum: 06.01.2016 [40] Stuart Russell, Peter Norvig: Künstliche Intelligenz, PEARSON, S.116-118 (2012) [41] Slota, George M. and Rajamanickam, Sivasankaran and Madduri, Kamesh: BFS and Coloring-based Parallel Algorithms for Strongly Connected Components and Related Problems, Proceedings of the 2014 IEEE 28th International Parallel and Distributed Processing Symposium [42] https://de.wikipedia.org/wiki/Zusammenhang_(Graphentheorie)#Gerichtete_Graphen Abrufdatum: 23.12.2015 [43] https://en.wikipedia.org/wiki/Tarjan%27s_strongly_connected_components_algorithm Abrufdatum: 16.12.2015 [44] https://de.wikipedia.org/wiki/Algorithmus_von_Tarjan_zur_Bestimmung_starker _Zusammenhangskomponenten Abrufdatum: 16.12.2015 [45] https://en.wikipedia.org/wiki/Label_Propagation_Algorithm Abrufdatum: 06.01.2016 [46] https://github.com/google/guava Abrufdatum: 16.02.2016 [47] https://github.com/carrotsearch/hppc Abrufdatum: 16.02.2016 [48] https://github.com/HdrHistogram/HdrHistogram Abrufdatum: 16.02.2016 [49] http://neo4j.com/docs/stable/configuration-io-examples.html Abrufdatum: 19.12.2015 [50] https://github.com/mirkonasato/graphipedia Abrufdatum: 16.12.2015 [51] https://github.com/neo4j/neo4j/issues/6399 Abrufdatum: 25.02.2016 79