Arbeit

Werbung
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
Herunterladen