Parallelisierung des Davis-Putnam-Algorithmus in Haskell

Werbung
Fachbereich Informatik und Mathematik
Institut für Informatik
Bachelorarbeit
Entwurf und Implementierung paralleler
Varianten des Davis-Putnam-Algorithmus
zum Erfüllbarkeitstest aussagenlogischer
Formeln in der funktionalen
Programmiersprache Haskell
Till Berger
25. Oktober 2012
eingereicht bei
Prof. Dr. Manfred Schmidt-Schauß
Künstliche Intelligenz/Softwaretechnologie
Zusammenfassung
Der Davis-Putnam-Algorithmus ist ein grundlegendes Verfahren, um die Erfüllbarkeit
einer aussagenlogischen Formel zu bestimmen. In dieser Arbeit wird untersucht, wie sich
der Algorithmus in der funktionalen Programmiersprache Haskell parallelisieren lässt, das
Ergebnis also mithilfe mehrerer Rechenkerne schneller berechnet werden kann. Ausgehend
von einer existierenden sequentiellen Implementierung des Verfahrens werden verschiedene
parallele Varianten implementiert und verglichen.
Erklärung gemäß Bachelor-Ordnung Informatik 2007 § 24 Abs. 11
Hiermit bestätige ich, dass ich die vorliegende Arbeit selbstständig verfasst habe und
keine anderen Quellen oder Hilfsmittel als die in dieser Arbeit angegebenen verwendet
habe.
Frankfurt am Main, den 25. Oktober 2012
Till Berger
Inhaltsverzeichnis
1 Motivation
8
2 Einführung in Haskell
2.1 Funktionale Programmiersprachen
2.2 Grundkonzepte von Haskell . . . .
2.2.1 Statische Typisierung . . .
2.2.2 Pattern Matching . . . . . .
2.2.3 Guards und if . . . . . . .
2.2.4 let und where . . . . . . .
2.2.5 Typklassen . . . . . . . . .
2.2.6 Eigene Datentypen . . . . .
2.3 Verzögerte Auswertung . . . . . . .
2.4 Ein- und Ausgabe . . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
10
10
11
12
13
14
14
15
15
16
16
3 Aussagenlogik
3.1 Konzept . .
3.2 Syntax . . .
3.3 Semantik .
3.4 Äquivalenz
3.5 Konjunktive
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
19
19
20
21
22
23
4 Das Erfüllbarkeitsproblem
4.1 Der Davis-Putnam-Algorithmus . . . . . . . . . . . . . . . . . . . . . . . .
4.2 Implementierung in Haskell . . . . . . . . . . . . . . . . . . . . . . . . . .
25
25
28
5 Parallelität und Nebenläufigkeit
5.1 Begriffsbestimmung . . . . . . . . . . . . . . . .
5.2 Parallelität und Nebenläufigkeit in Haskell . . .
5.2.1 Parallelisierung mit der Eval-Monade .
5.2.2 Die Par-Monade . . . . . . . . . . . . .
5.2.3 Nebenläufigkeit mit Concurrent Haskell
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
31
31
32
33
35
37
6 Parallelisierung des Davis-Putnam-Algorithmus
6.1 Ansatz . . . . . . . . . . . . . . . . . . . . .
6.2 Implementierungen . . . . . . . . . . . . . .
6.2.1 Eval-Monade . . . . . . . . . . . . .
6.2.2 Concurrent-Haskell-Varianten . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
39
39
41
42
43
6
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
Normalform
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
6.3
6.2.3 Par-Monade . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
6.2.4 Anmerkungen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Bezug zu existierenden Ansätzen . . . . . . . . . . . . . . . . . . . . . . .
7 Experimentelle Untersuchung
7.1 Testaufstellung . . . . . . . . . . . . . . . . . . . .
7.2 Ergebnisse . . . . . . . . . . . . . . . . . . . . . . .
7.2.1 Impliziter und expliziter Future . . . . . . .
7.2.2 Parallelisierungstiefe bei der Eval-Variante .
7.2.3 Vergleich von Eval und Con . . . . . . . . .
7.2.4 Parallelisierungstiefe bei Con’ . . . . . . . .
7.2.5 Parallelisierungstiefe bei Amb . . . . . . . .
7.2.6 Vergleich von Eval, Con’ und Amb . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
45
46
46
48
48
49
49
49
52
56
59
62
8 Fazit
67
Literaturverzeichnis
68
7
1 Motivation
Das Erfüllbarkeitsproblem der Aussagenlogik, auch kurz als SAT-Problem 1 bezeichnet,
gehört zu den grundlegenden Problemen der Informatik. Viele interessante Probleme lassen sich darauf reduzieren, da es ein NP-vollständiges Problem ist ([Coo71]). Zudem ist
die Kodierung eines Problems als aussagenlogische Formel in vielen Fällen relativ intuitiv. Konkrete Anwendungsgebiete finden sich zum Beispiel im Testen und Verifizieren von
Hardwareschaltungen und Software oder bei Planungsproblemen im Bereich der Künstlichen Intelligenz.
Insofern ist es nicht verwunderlich, dass auf diesem Gebiet intensiv geforscht wird. Die
Schwierigkeit besteht nicht darin, das Problem prinzipiell zu lösen, sondern darin, es effizient zu lösen. Das Verfahren, auf das ein Großteil der aktuellen Verfahren zurückgeht,
ist das 1960 von Martin Davis und Hilary Putnam vorgestellte ([DP60, DLL62]). Auch
wenn im Allgemeinen wegen der NP-Vollständigkeit des Problems gegenüber den existierenden Verfahren keine prinzipiell schnellere Lösung zu erwarten ist, erlauben es weiter
verfeinerte Algorithmen – und schnellere Hardware – immer größere Probleminstanzen
praktisch zu lösen. Dabei gibt es auch Ansätze, die sich auf bestimmte Teilklassen des Erfüllbarkeitsproblems beschränken, auf diesen aber sehr schnell arbeiten (eine Übersicht,
wenngleich etwas älter, ist in [KS04] zu finden).
Neben dem Versuch, das Problem aus rein sequentieller Sicht geschickter anzugehen, ist
ein weiterer Ansatz, die Lösungssuche durch das Ausnutzen mehrerer parallel arbeitender
Recheneinheiten zu beschleunigen, seien es nun mehrere Prozessorkerne, Prozessoren oder
gar vernetzte Rechner. Die meisten modernen Rechner besitzen schon Zwei- oder Vierkernprozessoren oder haben noch mehr Rechenkerne – und die Tendenz ist steigend. Die
Parallelisierung von Programmen ist also ein aktuelles Thema: Gerade bei rechenintensiven Aufgaben ist es wünschenswert, dass nicht ein Großteil der Rechenkraft brachliegen
bleibt.
Parallele SAT-Algorithmen gibt es schon länger: Zwei Ansätze sind zum Beispiel [BS96]
und [ZB96], die beide den Davis-Putnam-Algorithmus als Grundlage nutzen. Diese beschäftigen sich explizit mit der Kommunikation und Lastverteilung zwischen den Recheneinheiten: mehrere lokale Prozessoren in [BS96] und vernetzte Rechner in [ZB96]. In dieser
Arbeit soll untersucht werden, wie sich ein paralleler SAT-Algorithmus auf Grundlage des
Davis-Putnam-Algorithmus in der funktionalen Programmiersprache Haskell umsetzen
lässt. Der Ausgangspunkt ist dabei eine sequentielle Implementierung, die am Lehrstuhl
für Künstliche Intelligenz und Softwaretechnologie an der Goethe-Universität Frankfurt
entwickelt wurde. Da Haskell eine sehr abstrakte, funktionale Programmiersprache ist,
bieten die verfügbaren Parallelisierungsmethoden auch nur begrenzte Möglichkeiten, die
1
8
SAT steht für (boolean) satisfiability
Lastverteilung und Kommunikation zwischen den Recheneinheiten explizit zu definieren.
Parallel Haskell geht sogar soweit, dass die Prozesskommunikation vollständig implizit
definiert wird. Dadurch ist einerseits der zu erwartende Aufwand für die Implementierung gering. Tatsächlich lässt sich die einfachste Variante mit nur ein paar zusätzlichen
Codezeilen gegenüber dem sequentiellen Algorithmus umsetzen. Andererseits sollten klassische Probleme der nebenläufigen Programmierung wie Deadlocks oder Race Conditions
kein Problem sein. Natürlich stellt sich dann die Frage der Effizienz, denn um nichts
anderes als um die Beschleunigung des Verfahrens geht es ja: Wie viel bringt also die
Parallelisierung? Die Ergebnisse aus [BS96] deuten an, dass für widersprüchliche Formeln
ein Beschleunigungsfaktor, der der Anzahl der Prozessoren entspricht, möglich ist. Lässt
sich dies auch in Haskell reproduzieren? Und wie verhalten sich die Implementierungen
mit den verschiedenen Parallelisierungsmöglichkeiten im Vergleich? Dabei wird es nur um
die relative Geschwindigkeit gegenüber der Grundimplementierung gehen, nicht um einen
Vergleich mit Implementierungen in anderen Programmiersprachen.
In den folgenden Kapiteln wird zuerst eine kurze Einführung in die funktionale Programmiersprache Haskell gegeben, dann werden die Aussagenlogik und das Erfüllbarkeitsproblem mit dem Davis-Putnam-Algorithmus behandelt. Bevor dann die parallelen
Varianten vorgestellt werden, folgt noch ein Überblick über die Möglichkeiten, Parallelität
und Nebenläufigkeit mit Haskell umzusetzen.
9
2 Einführung in Haskell
2.1 Funktionale Programmiersprachen
Dieser Abschnitt beruht im Wesentlichen auf dem einleitenden Kapitel aus [SSS11b].
In einer funktionalen Programmiersprache besteht ein Programm nicht aus einer
Folge von Anweisungen, wie dies in einer imperativen Programmiersprache der Fall ist,
sondern aus einer Menge von Funktionsdefinitionen im engeren mathematischen Sinn.
Funktionale Programmiersprachen sind deklarativ, das heißt, man definiert nicht wie etwas
berechnet werden soll, sondern eher was. In einer rein funktionalen Programmiersprache
gilt das Prinzip der referentiellen Transparenz. Das heißt, dass die Auswertung einer
Funktion mit festen Argumenten immer denselben Wert liefert. Es wird nicht explizit
auf dem Speicher operiert, es können also keine Seiteneffekte auftreten, da Ausdrücke
nur implizit im Speicher abgelegt werden. Im Prinzip ist ein funktionales Programm ein
Ausdruck, der durch die Funktionsdefinitionen nur gegliedert wird. Beim Ausführen des
Programms wird der Ausdruck ausgewertet, das Ergebnis ist ein einzelner Wert.
Funktionale Programmiersprachen ermöglichen eine andere Sichtweise auf Problemlösungen, die vor allem für mathematische Problemstellungen häufig natürlicher ist. In
vielen Fällen lassen sich mathematische Funktionsdefinitionen fast eins zu eins umsetzen.
Durch die Abstraktion von der Speicherverwaltung werden viele Programmierfehler von
vornherein ausgeschlossen und die Konzentration auf das eigentliche Problem erleichtert.
Außerdem lassen sich Programmeigenschaften wie Korrektheit einfacher beweisen, wenn
keine Seiteneffekte auftreten können. Der hohe Abstraktionsgrad hat aber auch seinen
Preis. Gerade bei Sprachen mit verzögerter Auswertung ist es schwieriger nachzuvollziehen, was bei der Ausführung eines Programms genau passiert. Um die Gründe für
schlechtes Geschwindigkeits- oder Speicherverhalten zu erkennen, muss man mitunter die
Funktionsweise der Sprache sehr genau verstehen.
Haskell wurde 1987 mit dem Ziel ins Leben gerufen, eine rein funktionale Programmiersprache mit nicht-strikter Auswertung zu entwickeln ([HHJW07]). Haskell ist aktuell
durch den Haskell 2010 report 1 spezifiziert, der Syntax und Semantik von Haskell beschreibt. Es existieren verschiedene Implementierungen, wobei die am weitesten verbreitete der Glasgow Haskell Compiler (GHC) ist, der auch in dieser Arbeit benutzt wird.
Im Folgenden wird der Einfachheit halber keine weitere Unterscheidung zwischen Haskell
und GHC getroffen, auch wenn GHC einige Funktionen unterstützt, die sich nicht in der
Haskell-Spezifikation finden. Auch wird im weiteren immer von verzögerter Auswertung
(lazy evaluation oder call-by-need) gesprochen, obwohl Haskell lediglich Nicht-Striktheit
bei der Auswertung von Ausdrücken vorschreibt. Die verzögerte Auswertung ist nur eine
1
http://haskell.org/definition/haskell2010.pdf
10
mögliche Implementierung davon, wenn auch die am weitesten verbreitete.
2.2 Grundkonzepte von Haskell
Es folgt eine kurzer Überblick über die wichtigsten Konzepte von Haskell, um dem Leser das Nachvollziehen des später vorgestellten Programmcodes zu erleichtern. Für eine
ausführliche Einführung in Haskell sei das äußerst unterhaltsame Buch [Lip11] empfohlen.
Ein Haskell-Programm ist im Prinzip nur ein Ausdruck, der ausgewertet wird. Das
heißt, dass er anhand der Definitionen der verwendeten Ausdrücke solange umgeformt
wird, bis er nicht weiter reduziert werden kann. Dann befindet er sich in Normalform.
Nehmen wir zum Beispiel die Funktionsdefinition
double :: Int -> Int
double x = 2 * x
Die erste Zeile ist eine Typdeklaration. Jeder Ausdruck in Haskell hat einen festen Typ:
double wird hier deklariert als eine Funktion, die Ausdrücke des Typs Int (ganzzahlige Zahlwerte) auf Ausdrücke des Typs Int abbildet. Die zweite Zeile ist die eigentliche
Definition. Mit dem Ausdruck double 5 wird die Funktion double auf den Int-Wert 5
angewendet. Anhand der Definition wird er durch 2 * 5 ersetzt, was 10 ergibt. Dieser
Ausdruck ist wie für double deklariert wiederum vom Typ Int.
Funktionen mit mehreren Argumenten werden auf ähnliche Weise definiert:
add :: Int -> Int -> Int
add x y = x + y
Wie ist hier die Typdeklaration zu verstehen? Tatsächlich nimmt jede Funktion in
Haskell nur ein Argument entgegen. Eine Funktion mit zwei Argumenten ist eigentlich
eine Funktion, die ein Argument entgegennimmt und eine Funktion zurückliefert, die
wiederum nur ein Argument entgegennimmt und einen Wert als Ergebnis liefert. Obige
Typdeklaration wird intern so gelesen:
add :: Int -> (Int -> Int)
add ist also eine Funktion, die einen Int-Wert auf eine Funktion abbildet, die einen IntWert auf einen Int-Wert abbildet. Man kann die Definition von add auch so schreiben,
dass dies klarer wird:
add = \x -> (\y -> x + y)
\x -> d ist eine anonyme Funktion, die nur für den aktuellen Kontext definiert wird
(sie steht für den Lambdaausdruck λx.d). Sie bindet ihr Argument an den Namen x und
liefert den Ausdruck d zurück. Die Definition ist zu lesen als: add ist eine Funktion, die
einen Ausdruck des Typs Int entgegennimmt und die Funktion \y -> x + y (die den
Typ Int -> Int hat) zurückliefert. In dieser Funktion ist x nun eine Konstante. Für
jedes Argument c liefert die Anwendung desselben auf add also eine andere Funktion
zurück, nämlich eine, die die Konstante c auf ihr Argument addiert. Anders gesagt ist
11
add c einfach diese Funktion. Dieses Prinzip wird als currying 2 bezeichnet. Man kann es
auch so betrachten, dass man Funktionen mit mehreren Argumenten teilweise angewendet
benutzen kann. Das erlaubt es, Funktionen sehr flexibel einzusetzen, wenn man sie als
Argumente an andere Funktionen übergibt. Die Funktion map zum Beispiel wendet eine
Funktion auf jedes Element einer Liste an. map (add 5) [1,2,3] wird zu [6,7,8] ausgewertet. (Listen werden geschrieben, indem man ihre Elemente durch Kommas getrennt
in eckige Klammern einschließt.) Hätte man add wie folgt auf einem Tupel definiert:
add :: (Int, Int) -> Int
add (x, y) = x + y
müsste man obigen map-Ausdruck als map (\x -> add (5, x)) [1,2,3] schreiben oder
gar eine Funktion add5 zum Beispiel als add5 x = add (x, 5) definieren und dann
map add5 [1,2,3] schreiben. Haskell unterstützt also Funktionen höherer Ordnung: Funktionen können nicht nur Werte, sondern auch andere Funktionen als Argumente annehmen, und auch Funktionen als Ergebnis haben.
Die oben schon benutzten mathematischen Grundfunktionen * und + sind Infix-Operatoren. Man kann sie wie normale Funktionen benutzen, indem man sie in Klammern
schreibt; zum Beispiel (+) x y. Damit kann man sich, um eine Zahl auf jede Zahl in
einer Liste zu addieren, die obige Definition von add sparen, und einfach map ((+) 5)
[1,2,3] schreiben. Beliebige Funktionen mit zwei Argumenten kann man übrigens auch
in Infix-Notation benutzen, indem man ihren Namen in Akzentzeichen einschließt; zum
Beispiel x ‘add‘ y.
2.2.1 Statische Typisierung
Wie schon erwähnt, hat jeder Ausdruck in Haskell einen festen Typ. Haskell ist statisch
typisiert, der Typ eines Ausdrucks wird also zum Zeitpunkt des Kompilierens bestimmt.
Diesen muss man, wie bei obigen Funktionsdefinitionen, im Allgemeinen nicht explizit
deklarieren. Nur wenn der Compiler den Typ nicht eindeutig aus der Definition eines
Ausdrucks schließen kann, ist eine Typdeklaration unbedingt notwendig.
Neben Datentypen für Zahlen wie Int und Float, Wahrheitswerten (Bool), Zeichen
(Char) oder Zeichenketten (String) sind ein zentraler Datentyp Listen, wie wir sie schon
eben in der Anwendung der Funktion map gesehen haben. Eine Liste kann beliebig viele
Elemente eines festen Typs enthalten. Der Typ der Liste [1,2,3] ist zum Beispiel [Int].
Zeichenketten sind Listen von Zeichen, der Typ String ist als
type String = [Char]
definiert. Das Schlüsselwort type vergibt einen neuen Namen für einen bestehenden Typ.
Man kann Zeichenketten, wie in anderen Programmiersprachen üblich, in doppelten Anführungszeichen schreiben – durch die Definition als Liste sind aber keine speziellen
String-Funktionen nötig.
2
Diese Bezeichnung geht auf Haskell Curry zurück, der mit seinem Vornamen auch für die Programmiersprache selbst Pate stand ([HHJW07])
12
Haskell hat zudem ein polymorphes Typsystem, das parametrischen Polymorphismus
verwendet. Die Funktion map nutzt diesen, wie wir gleich sehen werden, um beliebige
Funktionen verarbeiten zu können, ohne deren konkreten Typ kennen zu müssen. In Typdeklarationen werden Typvariablen benutzt – im Gegensatz zu konkreten Typen kleingeschrieben –, um einen beliebigen Typen darzustellen.
2.2.2 Pattern Matching
Die Listenschreibweise mit Komma ist eine Kurzform: Listen werden mit dem Konstruktor
: von der leeren Liste [] ausgehend konstruiert. Die eben genannte Liste sieht dann so
aus: 1:2:3:[]. Das ist entscheidend für das Pattern Matching. Die Funktion map zum
Beispiel ist wie folgt definiert:
map :: (a -> b) -> [a] -> [b]
map _ []
= []
map f (x:xs) = f x : map f xs
Die Typdeklaration besagt: map nimmt eine Funktion entgegen, die Ausdrücke von Typ
a auf Ausdrücke von Typ b abbildet, und eine Liste von Typ a und liefert eine Liste von
Typ b zurück. a und b stehen für beliebige Typen, die natürlich auch gleich sein können.
In den nächsten zwei Zeilen folgen zwei Definitionen von map, die eine Fallunterscheidung treffen. Werte (also nicht Funktionen) können auf ihre Struktur überprüft werden.
Die erste Definition trifft nur auf leere Listen zu: Nur wenn das zweite Argument der
Struktur [] entspricht, also der leeren Liste, wird diese Definition angewendet. Die zweite
trifft auf Listen der Struktur x:xs zu, auf Listen, die aus einem Element x und einer
Restliste xs (möglicherweise die leere Liste) konstruiert sind. Das sind also Listen mit
mindestens einem Element. Damit sind alle Fälle abgedeckt. Für das erste Argument bedeutet der Unterstrich in der ersten Definition nur, dass dieses Argument ignoriert wird,
weil es in der Definition nicht verwendet wird.
Die zweite Definition konstruiert rekursiv die Ergebnisliste: Sie wendet f auf das erste
Element der aktuellen Liste an und hängt das Ergebnis vor die durch den rekursiven
Aufruf konstruierte Liste, dem der Rest der Liste übergeben wird. Ist die Restliste leer,
greift schließlich die erste Definition, die die leere Liste zurückliefert.
Fallunterscheidungen lassen sich auch mit dem case-Konstrukt treffen. So könnte man
map auch so schreiben (tatsächlich ist erstere Schreibweise nur syntaktischer Zucker für
die case-Schreibweise):
map :: (a -> b) -> [a] -> [b]
map f l =
case l of
[]
-> []
(x:xs) -> f x : map f xs
13
2.2.3 Guards und if
Allgemeinere Fallunterscheidungen kann man mit Guards oder if treffen. Statt die Fakultätsfunktion so zu definieren:
fak :: Int -> Int
fak 0 = 0
fak n = n * fak (n - 1)
kann man das mit if auch so:
fak :: Int -> Int
fak n = if n == 0 then 0 else n * fak (n - 1)
oder mit Guards so tun:
fak :: Int -> Int
fak n | n == 0
= 1
| otherwise = n * fak (n - 1)
Hier folgt nach dem Balken jeweils ein Ausdruck vom Typ Bool, also ein Ausdruck,
der zu einem Wahrheitswert ausgewertet wird. Wertet er zu True aus, wird die hinter
dem Gleichheitszeichen folgende Definition benutzt, sonst wird die nächste Zeile geprüft.
otherwise ist ein Synonym für True, fängt also alle Fälle ab, die vorher nicht abgedeckt
wurden.
Mit if oder Guards könnte man nun die Definition von fak ganz einfach auf negative
Zahlen erweitern:
fak :: Int -> Int
fak n | n < 1 = 1
| otherwise = n * fak (n - 1)
Die Guard-Syntax ist vor allem dann praktisch, wenn mehr als zwei Fälle zu unterscheiden sind.
2.2.4 let und where
Mit dem Schlüsselwort let lassen sich Namen für Teilausdrücke vergeben:
quadruple x = let y = x + x
in y + y
Mit where ebenso:
quadruple x = y + y
where y = x + x
Der Unterschied ist, dass where für die ganze Funktionsdefinition gilt, während die mit
let definierten Ausdrücke nur für den Ausdruck nach in gelten.
Das Beispiel stellt natürlich eine sehr umständliche Art dar, eine Zahl zu vervierfachen . . .
14
2.2.5 Typklassen
Im vorletzten Abschnitt haben wir der Einfachheit halber den Typ von add auf IntWerte beschränkt. Hätte man die Typdeklaration bei der Definition weggelassen, hätte
der Compiler den Typ wie folgt geschlossen:
add :: Num a => a -> a -> a
Das ist derselbe Typ wie der von +. Vor dem Doppelpfeil stehen die Typklassenbeschränkungen für die Typvariable a: Die Addition ist (wie auch Subtraktion und Multiplikation) für beliebige Typen definiert, solange sie der Typklasse Num angehören. Dadurch
kann man diese Funktionen nicht nur für Int-Werte verwenden, sondern zum Beispiel auch
für Float-Werte.
Typklassen fassen Typen mit gleichen Eigenschaften zusammen. In der Typklasse Num
sind Typen, die sich wie eine Zahl verhalten. Andere Typklassen sind zum Beispiel Eq
für Typen, deren Ausdrücke auf Gleichheit geprüft werden können, oder Show für Typen,
deren Ausdrücke eine (sinnvolle) Repräsentation als Text haben.
Typklassen stellen genauer gesagt ad-hoc-Polymorphismus zur Verfügung. Die Implementierung der zugehörigen Funktionen hängt hierbei vom konkreten Typ ab. Die Funktion show zum Beispiel, die einen Ausdruck in eine Zeichenkette umwandelt, kann nicht
jeden beliebigen Typen anzeigen, sondern nur solche, deren Typ der Typklasse Show angehört. Für diesen muss show konkret implementiert sein. Man spricht auch davon, dass
Funktionen auf diese Weise überladen werden.
2.2.6 Eigene Datentypen
Eigene Datentypen können mit dem data-Schlüsselwort definiert werden. Ein praktischer
in Haskell eingebauter Datentyp ist Maybe, der wie folgt definiert ist:
data Maybe a = Just a | Nothing
Damit lassen sich zum Beispiel Fehler auf einfache Weise behandeln. Ein Ausdruck
des Typs Maybe a enthält entweder einen Wert des Typs a (Just a) oder keinen Wert
(Nothing). Die Funktion lookup, die einen Schlüssel in einer Assoziationsliste sucht (einer Liste von Tupeln, wobei das erste Element des Tupels als Schlüssel, das zweite als
zugehöriger Wert betrachtet wird), verwendet Maybe, um den Fall zu melden, dass sich
der gesuchte Schlüssel nicht in der Liste befindet.
lookup :: (Eq a) => a -> [(a,b)] -> Maybe b
lookup _
[]
= Nothing
lookup key ((x,y):xys)
| key == x
= Just y
| otherwise
= lookup key xys
Mithilfe des Pattern Matchings kann man dann Werte des Typs Maybe durch den verwendeten Konstruktor, nämlich entweder Just a oder Maybe, unterscheiden.
15
2.3 Verzögerte Auswertung
Ein wichtiger Unterschied zu imperativen Sprachen ist, dass die Reihenfolge der Auswertung nicht festgelegt ist, denn wie schon erwähnt, definiert man ja nicht wie, sondern was
berechnet werden soll. Ausdrücke sind keine »Befehle« im Sinne imperativer Sprachen.
Man könnte aber, wenn man einen Ausdruck wie x + y sieht, denken, dass x vor y ausgewertet wird. Das ist aber nicht sicher. Durch Optimierungen des Compilers kann es
durchaus passieren, dass y vor x ausgewertet wird.
Haskell benutzt zudem die sogenannte verzögerte Auswertung. Ausdrücke werden
erst ausgewertet, wenn ihr Wert auch tatsächlich benötigt wird. Dadurch lassen sich unendliche Datenstrukturen wie beispielsweise unendliche Listen verwenden. [1..] definiert
die Liste aller natürlichen Zahlen. Würde man diese auswerten, würde das Programm in
eine Endlosschleife geraten bei dem Versuch, alle seine Elemente auszuwerten – also alle
natürlichen Zahlen. Wendet man aber darauf zum Beispiel eine Funktion wie take an, die
die ersten n Elemente einer Liste liefert, bekommt man ein Ergebnis: take 5 [1..] wird
zu [1,2,3,4,5] ausgewertet. Die Liste wird hier nur so weit wie nötig ausgewertet, denn
take braucht gar nicht zu wissen, wie die Liste nach den ersten fünf Elementen aussieht.
Das lässt sich ganz gut anhand der Definition von take veranschaulichen:
take
take
take
take
:: Int -> [a] -> [a]
n _
| n <= 0 = []
_ []
= []
n (x:xs)
= x : take (n-1) xs
In der vierten Zeile wird mit x:xs nur geprüft, ob die Liste aus einem Anfangselement
und einer Restliste besteht. Dafür ist es irrelevant, wie die Restliste aussieht. Mit x wird
eine neue Liste konstruiert, an die das Ergebnis des rekursiven Ausrufs von take gehängt
wird. Ist das erste Argument dabei irgendwann 0, trifft die erste Definition zu (n <= 0)
und hängt nur noch die leere Liste an.
In Sprachen mit strikter Auswertung, wo die Argumente vollständig ausgewertet würden, bevor die Funktion angewendet wird, wäre das so nicht möglich. Es gibt in Haskell
Möglichkeiten, die Auswertungsreihenfolge zu kontrollieren und Funktionen zum Beispiel
strikt zu machen. In Kapitel 5 (Parallelität und Nebenläufigkeit) werden später ein paar
Methoden dazu vorgestellt.
2.4 Ein- und Ausgabe
Der folgende Abschnitt hält sich grob an [Sab12] und [HHJW07], die Beispiele sind [Sab12]
entnommen.
Wie kann man aber nun mit der Außenwelt kommunizieren? Also zum Beispiel Dateien einlesen oder schreiben, Benutzereingaben entgegennehmen und ähnliches? Dafür
gibt es die IO-Monade. Monaden in Haskell gehen auf den mathematischen Begriff der
Monaden aus der Kategorientheorie zurück ([HHJW07], S. 23). Sie fassen in Haskell verschiedene Anwendungen mit ähnlicher Struktur zusammen, bei denen in irgendeiner Art
16
Seiteneffekte auftreten. Sie sind als Typklasse definiert3 :
class Monad m where
return :: a -> m a
(>>=) :: m a -> (a -> m b) -> m b
In der IO-Monade sind die Seiteneffekte Ein- und Ausgabe. Ein Ausdruck des Typs IO a
stellt eine Ein-/Ausgabe-Aktion dar, die beim Ausführen möglicherweise Seiteneffekte hat
und einen Wert des Typs a zurückgibt. Beim Starten eines Haskell-Programms wird die als
main definierte IO-Operation ausgeführt. Man kann sich eine IO-Operation so vorstellen,
dass sie als Eingabe einen Zustand der Welt erhält und einen Wert sowie einen neuen
Zustand der Welt zurückliefert. Als Haskell-Typ geschrieben:
type IO a = World -> (a, World)
In der IO-Monade wird also von der Idee her implizit die Welt als Argument durchgereicht. Da es praktisch unmöglich ist, den ganzen Weltzustand zu repräsentieren, führt
GHC Seiteneffekte tatsächlich aus und reicht nur einen Wert durch, der die Ordnung der
Operationen sicherstellt. Im Prinzip bleibt dadurch die referentielle Transparenz erhalten.
[PJW93] stellt diese Implementierung im Detail vor.
Mehrere IO-Operationen lassen sich mit der monadischen Funktion >>= (auch bind
genannt) aneinanderhängen. >>= erzeugt aus zwei IO-Operationen eine neue, indem die
erste ausgeführt wird, das Ergebnis an die zweite weitergereicht und dann diese ausgeführt
wird. Zum Beispiel lässt sich aus den beiden Funktionen getChar, die ein Zeichen von
der Standardeingabe einliest, das der Benutzer eingibt, und putChar, die ein Zeichen auf
die Standardausgabe ausgibt, eine Funktion schreiben, die ein Zeichen einliest und dieses
wieder ausgibt:
echo :: IO ()
echo = getChar >>= putChar
() ist der leere Typ; putChar gibt ein Zeichen als Seiteneffekt aus, liefert aber im
funktionalen Kontext keinen Wert zurück.
Für den Fall, dass eine folgende IO-Operation kein Argument verarbeitet, gibt es die
Funktion >>. Sie ist einfach durch >>= definiert, indem das Ergebnis der ersten Operation
ignoriert wird:
(>>) :: m a -> m b -> m b
(>>) x y = x >>= \_ -> y
Durch die Funktionen >>= und >> werden Operationen innerhalb einer Monade geordnet. Für die Ein- und Ausgabe ist das eine gewünschte Eigenschaft: Man kann das
Zeichen nicht ausgeben, bevor es gelesen wurde. Zudem gibt es aber mit der Funktion
unsafeInterleaveIO eine Möglichkeit, IO-Operationen solange hinauszuzögern, bis ihr
3
m steht dabei für einen Typkonstruktor, eine Funktion, die einen Typ a auf einen konkreten Typen
m a abbildet. Die Klasse Monad ist also eine Typkonstruktorklasse, was das Typklassenkonzept leicht
erweitert. Für Details hierzu sei auf [HHJW07] verwiesen.
17
Ergebnis gebraucht wird; womit man gezielt eine verzögerte Ausführung von Seiteneffekten einführen kann. Beispielsweise lässt sich so eine Datei verzögert einlesen, sodass sie
nicht komplett in den Speicher geladen werden muss.
Die folgende Beispielfunktion getTwoChars liest zwei Zeichen ein. Dabei liefert jeder der
beiden Aufrufe von getChar einen »reinen« Wert. Der Wert der Funktion muss aber vom
Typ IO a sein, damit sie als IO-Operation benutzt werden kann. Das ist der Zweck der
monadischen Funktion return: Sie dient lediglich dazu, einen Wert in einen monadischen
Typ zu verpacken.
getTwoChars :: IO (Char, Char)
getTwoChars = getChar >>= \x ->
getChar >>= \y ->
return (x, y)
Will man, wie in der Definition von getTwoChars, längere Folgen von monadischen
Operationen schreiben, wird die Verwendung von >>= und >> schnell umständlich. Dafür
gibt es die do-Notation:
getTwoChars :: IO (Char, Char)
getTwoChars = do x <- getChar
y <- getChar
return (x, y)
Das sieht dem Programmieren in imperativen Sprachen sehr ähnlich, wobei das return
nicht mit einem return in imperativen Sprachen zu verwechseln ist. Es muss nur am Ende
stehen, wenn der letzte Ausdruck noch nicht vom Typ IO a ist; ebenso kann es bei komplexeren Funktionen mitten in einer Definition auftauchen, ohne dass es die Ausführung
an dieser Stelle beenden würde.
18
3 Aussagenlogik
Die Geschichte der formalen Logik reicht bis ins Altertum zurück. Sie ist ursprünglich eine Disziplin der Philosophie. Seit dem 19. Jahrhundert wurde sie verstärkt als Grundlage
der Mathematik untersucht. Es gibt verschiedene formale Logiken; neben der Aussagenlogik unter anderem die Prädikatenlogik oder die Modallogik. Im Allgemeinen geht es
darum, den Wahrheitsgehalt von Aussagen aufgrund allgemeiner logischer Prinzipien zu
untersuchen. Für die Informatik bietet die Formalisierung logischer Zusammenhänge eine Grundlage, Problemstellungen zu definieren und damit automatisch lösen zu können.
Dieses Kapitel hält sich grob an Kapitel 13 aus [EMC+ 01], wo die Grundlagen der Aussagenlogik ausführlich behandelt werden.
3.1 Konzept
Die Aussagenlogik ist die elementarste Form einer formalen Logik. Die Idee ist, dass
Aussagen auf der Ebene elementarer logischer Satzverknüpfungen wie und, oder, nicht,
wenn . . . dann analysiert werden. Elementare Aussagesätze sind dabei Sätze wie
Heute regnet es
Ich trage einen Hut
die wahr oder falsch sein können. Diese können zum Beispiel durch und verknüpft werden:
Heute regnet es und ich trage einen Hut
Dabei stellt sich die Frage, wie die Wahrheit der Gesamtaussage von der Wahrheit der
elementaren Sätze abhängt. Im obigen Fall wird man die Gesamtaussage nur dann als
wahr betrachten, wenn beide Teilaussagen Heute regnet es und Ich trage einen Hut wahr
sind. So wird es auch in der Aussagenlogik aufgefasst. Zu beachten ist aber, dass die
Formalisierung der Verknüpfungen nicht alle natürlichsprachlichen Nuancen erfasst. Das
natürlichsprachliche und kann durchaus eine zeitliche Bedeutung haben, wie zum Beispiel
in dem Satz Es regnet und ich setze einen Hut auf, die von der Formalisierung in der
Aussagenlogik aber nicht erfasst wird.
Die elementaren Aussagen werden durch Aussagenvariablen dargestellt. Sie haben keine Bedeutung im üblichen Sinn, sondern ihre Bedeutung ist ihr Wahrheitswert. Ebenso
ist die Bedeutung einer zusammengesetzten Aussage ihr Wahrheitswert, der sich aus den
Wahrheitswerten der enthaltenen Aussagenvariablen und der Bedeutung der Verknüpfungen ergibt.
Stellen wir die obigen Aussagen als p und q dar, das und als ∧, so ist p ∧ q genau dann
wahr, wenn sowohl p als auch q wahr sind. In allen anderen Fällen ist p ∧ q falsch.
19
3.2 Syntax
Die elementaren Zeichen (das Alphabet) einer aussagenlogischen Sprache sind eine Menge
P von Aussagenvariablen, die Junktorsymbole ¬, ∨, ∧, →, ↔, > und ⊥ und die Klammern
( und ).
Definition 3.2.1 (Aussagenlogische Formel). Sei P eine Menge von Aussagenvariablen.
Die Menge der aussagenlogischen Formeln Form(P ) ist wie folgt rekursiv definiert:
– Jede Aussagenvariable aus P ist eine aussagenlogische Formel.
– Die Junktorsymbole > und ⊥ sind aussagenlogische Formeln.
– Ist φ eine aussagenlogische Formel, so auch ¬φ.
– Sind φ und ψ aussagenlogische Formeln, so auch (φ ∨ ψ), (φ ∧ ψ), (φ → ψ) und
(φ ↔ ψ).
Die Junktoren liest man dabei üblicherweise wie folgt:
¬φ
nicht φ (Negation)
φ∨ψ
φ oder ψ (Disjunktion)
φ∧ψ
φ und ψ (Konjunktion)
φ→ψ
φ impliziert ψ (Implikation)
φ↔ψ
φ genau dann, wenn ψ (Äquivalenz)
>
verum (wahr)
⊥
falsum (falsch)
Zu beachten ist hierbei, dass die Symbole erst einmal bedeutungslos sind. Sie erhalten
ihre Bedeutung erst durch die im nächsten Abschnitt definierte Semantik. Das objektsprachliche genau dann, wenn (mit dem Symbol ↔) oder das und (mit dem Symbol ∧)
sind nicht zu verwechseln mit den entsprechenden metasprachlichen Ausdrücken, also mit
den Ausdrücken, mit denen über die Aussagenlogik gesprochen wird (für die Metasprache
werden häufig zur Unterscheidung Symbole mit Doppellinien verwendet: zum Beispiel ⇒
oder ⇔).
Um gerade bei komplexen Formeln nicht zu viele Klammern schreiben zu müssen, legen
wir folgende Klammerkonventionen fest:
1. Außenklammern können weggelassen werden.
2. ¬ bindet stärker als die anderen Junktoren.
3. ∧ und ∨ binden stärker als → und ↔.
20
4. Bei iterierter Verknüpfung mit ∧, ∨ oder ↔ wird Linksklammerung angenommen.
Beispiel 3.2.1. Sei P eine Menge von Aussagenvariablen und p, q, r, s ∈ P . Dann sind
folgende Ausdrücke aussagenlogische Formeln:
– p
– >
– ¬q
– p ∨ ¬p
– (p → (q → r)) ∧ s
– p∧q →r
– ⊥↔p
– ((p ∨ q) ∧ (p ∨ r) ∧ (p ∨ s) ↔ p ∨ (q ∧ r ∧ s)) ↔ ⊥
3.3 Semantik
Die Bedeutung einer aussagenlogischen Formel ist nicht irgendein durch sie ausgedrückter Sinn, sondern allein ihr Wahrheitswert: Sie ist entweder wahr oder falsch 1 . Die Bedeutung der Aussagenvariablen ist durch eine Wahrheitsbelegung festgelegt, die jedem
Symbol einen Wahrheitswert zuordnet. Die Junktorsymbole werden als Funktionen aufgefasst, die von Wahrheitswerten auf Wahrheitswerte abbilden. Diese sind dabei fest definiert, während die Belegung der Aussagenvariablen je nach Interpretation variiert. Der
Wahrheitswert einer Formel ergibt sich dann aus der Belegung der Aussagenvariablen
und den Funktionen der Junktoren. Formal gesprochen induziert jede Wahrheitsbelegung
B : P → {wahr, falsch} eine Abbildung B ∗ : Form(P ) → {wahr, falsch}:
Definition 3.3.1 (Auswertung einer aussagenlogischen Formel). Ist P eine Menge von
Aussagenvariablen und B : P → {wahr, falsch} eine Wahrheitsbelegung, so ist die Auswertung einer aussagenlogischen Formel B ∗ : Form(P ) → {wahr, falsch} wie folgt rekursiv
definiert:
B ∗ (p) = B(p) für p ∈ P
B ∗ (>) = wahr
B ∗ (⊥) = falsch
(
∗
B (¬φ) =
1
wahr
falsch
falls B ∗ (φ) = falsch
sonst
Diese beiden Ausdrücke sind, wie die in der Syntax definierten Symbole, Teil der Objektsprache. Im
Folgenden werden sie durch Kursivdruck kenntlich gemacht und sollten nicht mit den gleich benannten
natürlichsprachlichen, also metasprachlichen, Ausdrücken verwechselt werden.
21
(
wahr
falsch
falls B ∗ (φ) = wahr oder B ∗ (ψ) = wahr
sonst
(
wahr
falsch
falls B ∗ (φ) = wahr und B ∗ (ψ) = wahr
sonst
(
falsch
wahr
falls B ∗ (φ) = wahr und B ∗ (ψ) = falsch
sonst
∗
B (φ ∨ ψ) =
B ∗ (φ ∧ ψ) =
B ∗ (φ → ψ) =
(
∗
B (φ ↔ ψ) =
wahr
falsch
falls B ∗ (φ) = B ∗ (ψ)
sonst
Das oder in obiger Definition schließt auch den Fall ein, dass beide Teilformeln wahr
sind (im Gegensatz zu entweder oder).
Eine Formel φ heißt gültig bei B, wenn B ∗ (φ) = wahr. Wir schreiben dann B |= φ. Sie
heißt ungültig bei B, wenn B ∗ (φ) = falsch. Wir schreiben dann B 6|= φ.
In obiger Definition haben wir den Wahrheitswert einer beliebigen Formel in Abhängigkeit einer festen Belegung definiert. Meist interessiert uns aber, welche Belegungen eine
bestimme Formel wahr oder falsch machen.
Definition 3.3.2 (Erfüllbar). Sei P eine Menge von Aussagenvariablen. Eine Formel φ
heißt erfüllbar, wenn eine Belegung B : P → {wahr, falsch} existiert, sodass B |= φ.
Definition 3.3.3 (Allgemeingültig). Sei P eine Menge von Aussagenvariablen. Eine
Formel φ heißt allgemeingültig oder tautologisch, wenn für alle Belegungen B : P →
{wahr, falsch} gilt, dass B |= φ.
Definition 3.3.4 (Kontradiktorisch). Sei P eine Menge von Aussagenvariablen. Eine
Formel φ heißt kontradiktorisch oder unerfüllbar, wenn für alle Belegungen B : P →
{wahr, falsch} gilt, dass B 6|= φ.
3.4 Äquivalenz
Viele unterschiedliche Formeln wie zum Beispiel p ∧ q und q ∧ p würden wir intuitiv als
gleichwertig betrachten. Dieser Sachverhalt ist durch den Begriff der logischen Äquivalenz
erfasst.
Definition 3.4.1 (Logische Äquivalenz). Sei P eine Menge von Aussagenvariablen. Zwei
Formeln φ und ψ heißen logisch äquivalent, wenn für jede Belegung B : P → {T, F } gilt:
B |= φ genau dann, wenn B |= ψ. Wir schreiben φ ≡ ψ.
Zwei aussagenlogische Formeln sind also genau dann logisch äquivalent, wenn sie bei
denselben Belegungen gültig sind. Konsequenzen dieser Definition sind zum Beispiel, dass
22
∨ und ∧ kommutativ und assoziativ sind. So gilt für alle Formeln φ, ψ und χ:
φ∧ψ ≡ ψ∧φ
φ ∧ (ψ ∧ χ) ≡ (φ ∧ ψ) ∧ χ
φ∨ψ ≡ ψ∨φ
φ ∨ (ψ ∨ χ) ≡ (φ ∨ ψ) ∨ χ
Aber es gilt auch, dass alle allgemeingültigen Formeln logisch äquivalent sind und ebenso
alle kontradiktorischen Formeln:
φ ∨ ¬φ ≡ >
φ ∧ ¬φ ≡ ⊥
Dass diese Äquivalenzen tatsächlich gelten, weisen wir hier nicht formal nach.
3.5 Konjunktive Normalform
Um für eine Formel zu entscheiden, ob sie erfüllbar ist, kann man auch jede beliebige logisch äquivalente Formel auf Erfüllbarkeit testen – das ist eine direkte Folge der Definition
der logischen Äquivalenz. Jede beliebige Formel lässt sich in eine logisch äquivalente konjunktive Normalform überführen, die sich für den Erfüllbarkeitstest, wie wir später sehen
werden, anbietet. (Es gibt auch noch andere Normalformen, insbesondere die disjunktive;
der Einfachheit halber bleiben sie hier aber außen vor.)
Im Folgenden bezeichnet der Begriff Literal ein Aussagensymbol (p, positves Literal)
oder die Negation eines Aussagensymbols (¬p, negatives Literal).
Definition 3.5.1 (Konjunktive Normalform). Sei P eine Menge von Aussagenvariablen.
Eine Formel φ ∈ Form(P ) ist in konjunktiver Normalform, wenn sie von der Form φ1 ∧
. . . ∧ φn ist, wobei jedes φi von der Form ψi,1 ∨ . . . ∨ ψi,mi mit Literalen ψi,j ist.
Die konjunktive Normalform ist also eine Konjunktion von Disjunktionen von Literalen.
Beispielsweise sind die Formeln (p ∨ q ∨ r) ∧ (p ∨ q ∨ ¬r) ∧ (¬p ∨ ¬q) und (¬p ∨ r) ∧ q in
konjunktiver Normalform.
Offensichtlich tauchen in einer Formel in konjunktiver Normalform niemals die Junktoren →, ↔, > oder ⊥ auf. Sie sind auch gar nicht nötig, denn Formeln mit diesen Junktoren
lassen sich in logisch äquivalente Formeln ohne sie überführen:
– φ → ψ ≡ ¬φ ∨ ψ
– φ ↔ ψ ≡ (φ → ψ) ∧ (ψ → φ)
– φ ∨ ¬φ ≡ >
– φ ∧ ¬φ ≡ ⊥
23
Satz 3.5.1. Sei P eine Menge von Aussagenvariablen. Zu jeder Formel φ ∈ Form(P )
kann eine Formel ψ ∈ Form(P ) in konjunktiver Normalform konstruiert werden, sodass
φ ≡ ψ.
Der Beweis dieses Satzes nutzt obige Tatsache und kann in [EMC+ 01] nachgelesen werden.
Man könnte sich auch noch entweder ∨ oder ∧ sparen oder sogar nur mit einem einzelnen
Junktor auskommen, der alle anderen ausdrücken kann; das ist aber eher für theoretische
Betrachtungen interessant. Für eine ausführliche Diskussion von solchen Junktorbasen sei
auf [EMC+ 01] verwiesen.
24
4 Das Erfüllbarkeitsproblem
Wie eingangs erwähnt, interessiert uns nun, ob eine gegebene aussagenlogische Formel
erfüllbar ist. Ein naiver Algorithmus, den man auch als Wahrheitstafelmethode (siehe
[EMC+ 01]) bezeichnet, berechnet für alle möglichen Wahrheitsbelegungen der in der Formel enthaltenen Aussagensymbole (die Belegung nicht enthaltener Symbole ist offensichtlich irrelevant) den Wahrheitswert der Formel. Ist sie bei mindestens einer der Belegungen
wahr, so ist sie (nach Definition) erfüllbar, andernfalls ist sie kontradiktorisch.
Dieses Verfahren ist allerdings sehr aufwändig, da es 2n mögliche Belegungen gibt, wenn
die Formel n Aussagensymbole enthält. Das Problem ist zwar NP-vollständig, wie 1971
von Stephen Cook in [Coo71] gezeigt wurde. Unter der Annahme, dass die Problemklassen P und NP nicht identisch sind – was nicht bewiesen ist, aber gemeinhin angenommen
wird –, bedeutet das, dass es keinen Algorithmus geben kann, der das Problem im Allgemeinen schneller als in exponentieller Laufzeit löst; also in polynomieller Laufzeit1 .
Trotzdem gibt es deutlich schnellere Verfahren als die Wahrheitstafelmethode.
Einer, auf dem viele moderne Algorithmen für das Erfüllbarkeitsproblem beruhen, ist
der Davis-Putnam-Algorithmus. Er wurde 1960 in [DP60] als Teil eines Algorithmus vorgestellt, der für eine Formel der Prädikatenlogik bestimmt, ob diese allgemeingültig ist2 .
Häufig ist mit Davis-Putnam-Algorithmus aber der Algorithmus mit der Modifikation gemeint, die zwei Jahre später in [DLL62] vorgeschlagen wurde. Diese Variante wird auch
als Davis-Putnam-Logemann-Loveland-Algorithmus bezeichnet. Wir beziehen uns hier auf
den modifizierten Algorithmus, sprechen aber einfach von Davis-Putnam-Algorithmus.
4.1 Der Davis-Putnam-Algorithmus
Der Davis-Putnam-Algorithmus arbeitet auf einer Formel in Klauselrepräsentation. Das
ist eine spezielle Repräsentation der konjunktiven Normalform, in der die Disjunktionen
der Literale als Mengen von Literalen dargestellt werden (den sogenannten Klauseln)
und die Konjunktion dieser Disjunktionen als Menge der entsprechenden Klauseln. Die
Klauselrepräsentation einer Formel
ψ = (ψ1,1 ∨ . . . ∨ ψ1,m1 ) ∧ . . . ∧ (ψn,1 ∨ . . . ∨ ψ1,mn )
ist
1
2
Eine gute informelle Einführung in die Komplexitätstheorie findet sich in [Hay97]
Die Prädikatenlogik ist zwar ausdrucksstärker als die Aussagenlogik; die Allgemeingültigkeit einer prädikatenlogischen Formel ist aber nur semientscheidbar, das heißt, der Algorithmus würde bei einer
nicht allgemeingültigen Formel in eine Endlosschleife geraten. Die Erfüllbarkeit ist noch nicht einmal
semientscheidbar. Es kann also keinen Algorithmus geben, der die Erfüllbarkeit einer prädikatenlogischen Formel entscheiden kann (siehe [EMC+ 01]).
25
Sψ = {{ψ1,1 , . . . , ψ1,m1 }, . . . , {ψn,1 , . . . , ψ1,mn }}
Dabei werden Kommutativität und Assoziativität von ∨ und ∧ ausgenutzt. Die Reihenfolge der Klauseln und der Literale innerhalb der Klauseln ist egal, alle Varianten sind
logisch äquivalent. Außerdem kann durch die Mengenrepräsentation ein und dasselbe Literal (oder ein und dieselbe Klausel) nicht mehrfach vorkommen. Das ändert offensichtlich
aber auch nichts an der Bedeutung der Formel (φ ∨ φ ≡ φ und φ ∧ φ ≡ φ für alle Formeln
φ).
Nach Satz 3.5.1 kann jede Formel in eine logisch äquivalente Formel in konjunktiver
Normalform umgewandelt werden und offensichtlich gibt es damit auch eine Klauselrepräsentation dieser Formel. Die Umwandlung einer Formel in Klauselrepräsentation werden
wir hier nicht weiter untersuchen und im Folgenden immer annehmen, dass die zu prüfende Formel schon so vorliegt.
Diese Umwandlung ist übrigens im schlimmsten Fall selbst schon exponentiell. Es gibt
allerdings einen schnelleren Algorithmus, der polynomielle Laufzeit hat. Die erzeugte Formel ist zwar nicht logisch äquivalent zur Ursprungsformel, weil die Tautologie-Eigenschaft
verloren gehen kann, aber es gilt, dass die neue Formel genau dann unerfüllbar ist, wenn
die Ursprungsformel unerfüllbar ist. Aus einer erfüllbaren Formel wird dadurch also nicht
plötzlich eine unerfüllbare. Die Belegungen, bei denen beide erfüllbar sind, können sich
aber unterscheiden. Diese Eigenschaft reicht für den Erfüllbarkeitstest aus (siehe [SS11],
Kapitel 3).
Die Idee des Davis-Putnam-Algorithmus ist, dass in jedem Schritt eine Teilbelegung
einer einzelnen Variablen vorgenommen und die Formel mithilfe dieser Teilbelegung vereinfacht wird, sodass die neue Formel genau dann unerfüllbar ist, wenn die ursprüngliche
Formel unerfüllbar ist. Zuerst wird versucht, sichere Teilbelegungen vorzunehmen, also solche, die auf jeden Fall zu einer erfüllenden Belegung gehören, wenn eine solche existiert.
Erst wenn keine solche sichere Teilbelegung mehr vorgenommen werden kann, werden
beide mögliche Belegungen für eine einzelne Variable getrennt geprüft.
Definition 4.1.1 (Davis-Putnam-Algorithmus). Sei C eine Formel in Klauselrepräsentation. Der Algorithmus geht wie folgt rekursiv vor:
1. Ist C die leere Menge, dann ist die Formel erfüllbar.
2. Enthält C die leere Menge, dann ist die Formel unerfüllbar.
Regel I Gibt es eine Klausel mit nur einem Literal l, entferne alle Klauseln, die l enthalten und entferne ¬l aus allen Klauseln. Wende den Algorithmus auf die resultierende
Klauselmenge an.
Regel II Gibt es eine Variable p, die ausschließlich positiv oder ausschließlich negativ
(¬p) vorkommt, so entferne alle Klauseln, die sie enthalten. Wende den Algorithmus
auf die resultierende Klauselmenge an.
26
Regel III Trifft keine der obigen Fälle zu, wähle eine Variable p aus der Klauselmenge
und wende den Algorithmus auf die Klauselmengen C ∪ {p} und C ∪ {¬p} an. Die
Formel ist genau dann unerfüllbar, wenn beide Klauselmengen unerfüllbar sind (und
erfüllbar, wenn mindestens eine der beiden erfüllbar ist).
Ein formaler Nachweis, dass der Algorithmus korrekt und vollständig funktioniert,
bleibt hier aus. Wir wollen im Folgenden aber skizzieren, warum er funktioniert.
Zu Beginn wird geprüft, ob eine Lösung gefunden wurde: Ist C die leere Menge, dann
ist die Formel erfüllbar, weil Klauseln durch die drei Regeln nur dann aus C entfernt
werden, wenn ihr Wert in allen erfüllenden Belegungen wahr ist. Und sind alle Klauseln
wahr, dann ist es ihre Konjunktion auch (> ∧ > ≡ >). Enthält C die leere Klausel, dann
ist die Formel unerfüllbar, denn es wurden vorher nur Literale mit dem Wert falsch aus
der Klausel entfernt. Eine Disjunktion von falsch-Werten hat ebenfalls den Wert falsch
(⊥ ∧ ⊥ ≡ ⊥) und eine Konjunktion mit einem falsch-Wert ist falsch (φ ∧ ⊥ ≡ ⊥ für alle
Formeln φ).
Die zentrale Regel ist Regel I. Ein Literal l, das alleine in einer Klausel vorkommt,
muss den Wert wahr haben, wenn die Formel erfüllbar ist. Mit dem Wert falsch hätte
die Konjunktion der Klauseln den Wert falsch, damit wäre die Formel nicht erfüllt (φ ∧
⊥ ≡ ⊥). Also können alle Klauseln, die l enthalten, entfernt werden, denn der Wert der
Konjunktion der Klauseln verändert sich dadurch nicht (φ ∨ > ≡ > und φ ∧ > ≡ φ).
Ebenso kann aus allen Klauseln ¬l entfernt werden, da es damit den Wert falsch hat.
Der Wert der einzelnen Klausel hängt dann nur noch vom Wert der anderen Literale in
der Klausel ab, da es sich ja um eine Disjunktion handelt (φ ∨ ⊥ ≡ φ). Es wird also
im Prinzip eine Teilbelegung vorgenommen, die eine vereinfachte Klauselmenge liefert,
die genau dann unerfüllbar ist, wenn die ursprüngliche Klauselmenge unerfüllbar ist. Zu
beachten ist hier, dass l für eine Variable p oder ihre Negation ¬p steht. Ist l = ¬p, so ist
¬l = ¬(¬p) ≡ p.
Regel II: Eine Variable p, die ausschließlich positiv oder ausschließlich negativ vorkommt, kann sicher den Wert wahr (bzw. falsch) erhalten. Diese Teilbelegung ist auf
jeden Fall Teil einer erfüllenden Belegung, wenn die Formel erfüllbar ist. Denn gibt es
eine erfüllende Belegung, die die Variable mit falsch (bzw. wahr) belegt, so gibt es auch
eine, die sie mit dem anderen Wert belegt. Somit können alle Klauseln die p (bzw. ¬p)
enthalten, entfernt werden. Da ¬p (bzw. p) nicht vorkommt, braucht es, anders als in
Regel I, nicht entfernt zu werden.
Kann keine sichere Teilbelegung vorgenommen werden, so wird in Regel III eine Variable p ausgewählt, deren beide Belegungsvarianten geprüft werden: Die Klauselmenge
wird um jeweils eine Klausel, die nur p bzw. ¬p enthält, erweitert und der Algorithmus
auf beide Varianten angewendet. Damit greift Regel I in beiden rekursiven Aufrufen und
nimmt genau die gewünschte Teilbelegung vor. Die Formel ist nur dann erfüllbar, wenn
mindestens eine der beiden Varianten erfüllbar ist – denn eine Variable muss ja entweder
mit wahr oder falsch belegt werden.
Insbesondere für Regel III ist interessant, wie die Aussagenvariable genau ausgewählt
wird. Anstatt die erste oder eine zufällige zu wählen, ist es geschickter, eine aus den
27
kürzesten Klauseln zu wählen, weil so die Formel tendenziell schneller verkleinert werden
kann (dadurch, dass Regel I früher angewendet werden kann) ([SS11]).
Beispiel 4.1.1. Veranschaulichen wir uns die Arbeitsweise des Algorithmus am Beispiel
folgender Formel in Klauselmenge:
{{p, ¬q}, {p, ¬q, r}, {p, q, ¬r}, {¬p, q}, {¬p, ¬q, r}, {¬p, ¬q, ¬r}}
Im ersten Schritt gibt es weder eine einelementige Klausel noch ein ausschließlich positives oder negatives Literal. Wählen wir für Regel III p aus, fügen es der Klauselmenge
hinzu und wenden Regel I an:
{{q}, {¬q, r}, {¬q, ¬r}}
Nun steht q allein in einer Klausel, womit Regel I greift:
{{r}, {¬r}}
Egal ob nun beim erneuten Anwenden von Regel I r oder ¬r gewählt wird, das Ergebnis
ist die Klauselmenge mit der leeren Klausel:
{{}}
Die Belegung von p mit wahr führt folglich nicht zu einer erfüllenden Belegung. Also
gehen wir zum ersten Schritt zurück, belegen ¬p mit wahr und wenden Regel I an:
{{¬q}, {¬q, r}, {q, ¬r}}
Es greift Regel I für ¬q. Zurück bleibt nur eine Klausel mit ¬r:
{{¬r}}
Nach nochmaligem Anwenden von Regel I (für ¬r) bleibt die leere Klauselmenge übrig:
{}
Somit ist die ursprüngliche Formel erfüllbar.
4.2 Implementierung in Haskell
Werfen wir einen kurzen Blick auf die Haskell-Implementierung des Davis-Putnam-Algorithmus,
wie sie am Lehrstuhl für Künstliche Intelligenz und Softwaretechnologie an der GoetheUniversität Frankfurt existiert. Sie ist der Ausgangspunkt dieser Arbeit.
Klauselmengen sind durch die drei Haskelltypen
type Literal
= Int
type Klausel
= [Literal]
type KlauselMenge = [Klausel]
28
definiert. Aussagenvariablen sind positive Zahlen, negierte Aussagenvariablen negative
Zahlen, zusammengefasst als Typ Literal. Klauselmengen und Klauseln sind als Listen
definiert.
Die Implementierung weist ein paar Unterschiede zur Definition des Algorithmus im
vorigen Abschnitt auf.
davisPutnam :: KlauselMenge -> [Literal]
Wie anhand der Typdeklaration zu erkennen ist, liefert die Funktion davisPutnam eine
erfüllende Belegung in Form einer Liste von Literalen3 als Ergebnis, wenn die Formel
erfüllbar ist, und nicht nur einen Wahrheitswert, der angibt, ob die Formel erfüllbar ist
oder nicht. Dabei tauchen nur Variablen in der Liste auf, die in einem der rekursiven
Schritte auch gewählt wurden. Die Belegung der anderen Variablen ist beliebig. Wird
die leere Liste zurückgegeben, ist die Formel unerfüllbar. Man könnte die leere Liste
allerdings im obigen Sinne auch so auffassen, dass die Belegung aller Variablen beliebig
ist. Diese Doppeldeutigkeit besteht aber nur für die leere Klauselmenge, also die Eingabe
[]. Enthält die Eingabe mindestens ein Literal, wählt der Algorithmus auch mindestens
eines aus, womit die Lösungsliste nicht leer bleibt, wenn die Formel erfüllbar ist.
davisPutnam klauselMenge = davisPutnamSat klauselMenge []
davisPutnam ruft die Unterfunktion davisPutnamSat auf, die den eigentlichen Algorithmus darstellt und die aktuelle Lösung als zweites Argument mit sich trägt. So kann
in jedem Rekursionsschritt die aktuelle Teilbelegung erweitert werden. Deswegen wird ihr
zum Start die leere Liste übergeben.
Algorithmus 4.1 bildet davisPutnamSat ab. Zuerst wird in Zeile 2 geprüft, ob die Klauselmenge leer ist; das entspricht Punkt 1 in der Definition des Davis-Putnam-Algorithmus.
In diesem Fall ist das Ergebnis einfach das zweite Argument: eine erfüllende Belegung.
Ist die Klauselmenge nicht leer, so wird mit elem geprüft, ob sie die leere Klausel enthält:
Das ist Punkt 2 in der Definition. Das Ergebnis ist die leere Menge – die Formel ist unerfüllbar. Andernfalls wird Regel I angewendet: findUnit sucht eine Klausel mit einem
einzelnen Literal. Der Name kommt daher, dass eine solche Klausel auch als Unit-Klausel
bezeichnet wird.
findUnit :: KlauselMenge -> Maybe Literal
Ist das Ergebnis Just u, wurde also eine solche Klausel gefunden, entfernt resolveUnit
wie oben beschrieben alle Klauseln mit u und entfernt -u (also ¬u) aus allen Klauseln.
Der rekursive Aufruf erfolgt auf der resultierenden Klauselmenge. Die aktuelle Belegung
wird außerdem um u erweitert.
Wurde keine Unit-Klausel gefunden (Nothing), wird Regel III angewendet, wobei in
den beiden rekursiven Aufrufen nicht die Klauselmenge zuerst erweitert, sondern gleich
vereinfacht wird. Damit spart man sich die Suche der hinzugefügten Unit-Klausel, also
einen Rekursionsschritt. minUnit bezeichnet das innerhalb aller kürzesten Klauseln am
3
Auch wenn der Typ Klausel identisch zu [Literal] ist, ist an dieser Stelle aber keine Klausel, also eine
Disjunktion von Literalen, sondern eine Belegung gemeint. Der Typ Literal wird hier doppeldeutig
für eine Variable und ihre Belegung verwendet.
29
Algorithmus 4.1 Der Kern des Davis-Putnam-Algorithmus in Haskell
davisPutnamSat :: KlauselMenge -> [Literal] -> [Literal]
davisPutnamSat [] loesung = loesung
davisPutnamSat klauselMenge loesung
| [] ‘elem‘ klauselMenge = []
| otherwise =
case findUnit klauselMenge of
Just u ->
davisPutnamSat (resolveUnit u klauselMenge)
(u:loesung)
Nothing ->
let minUnit = findBestLiteral klauselMenge
positivePath = davisPutnamSat
(resolveUnit minUnit klauselMenge)
(minUnit:loesung)
negativePath = davisPutnamSat
(resolveUnit (- minUnit) klauselMenge)
((- minUnit):loesung)
in
case positivePath of
[] -> negativePath
xs -> xs
häufigsten vorkommende Literal, welches findBestLiteral findet. positivePath und
negativePath bezeichnen die beiden rekursiven Aufrufe. Zuerst wird der positive Fall
getestet und dessen Ergebnis (xs) zurückgeliefert, sollte die Formel erfüllbar sein. Ansonsten wird das Ergebnis des negativen Falls benutzt (entweder eine erfüllende Belegung
oder die leere Menge)4 .
Regel II entfällt in dieser Implementierung, weil sie sich zumindest in ein paar informellen Tests negativ auf die Laufzeit auswirkte. Die Suche nach einer der Regel entsprechenden Variable kostet also mehr Zeit, als die Verkleinerung des Suchraums durch sie spart.
Das mag aber auch an der ineffizienten Implementierung liegen – die Laufzeit liegt im
schlimmsten Fall in O(n2 ), wobei n die Anzahl der Literale ist. Dieser Sachverhalt wurde
hier aber nicht weiter untersucht.
4
In Bezug auf die Geschwindigkeit des Algorithmus sollte es im Allgemeinen egal sein, ob man den positiven oder negativen Fall zuerst prüft, wenn es nur um die Erfüllbarkeit geht; denn jede Klauselmenge
kann durch Negation aller Literale in eine Klauselmenge umgewandelt werden, die strukturgleiche erfüllende Belegungen hat. Die Wahrheitswerte sind bei diesen einfach umgedreht, also falsch statt wahr
und umgekehrt. Bei konkreten Formeln kann natürlich die eine oder die andere Variante schneller sein.
30
5 Parallelität und Nebenläufigkeit
5.1 Begriffsbestimmung
Bevor wir uns anschauen können, wie der Davis-Putnam-Algorithmus in Haskell parallelisiert werden kann, klären wir zuerst die Begriffe der Parallelität und Nebenläufigkeit und
schauen uns die Methoden an, die Haskell dazu bietet. Die Unterscheidung von Parallelität
und Nebenläufigkeit lehnt sich hierbei an [Sab12] an.
Parallelisierung bedeutet, dass die Ausführung eines Programms auf mehrere Rechenkerne oder Prozessoren verteilt wird, die die Teilberechnungen parallel ausführen. Dadurch
soll das Programm schneller als nur auf einem Prozessorkern ausgeführt werden.
Nebenläufigkeit bezeichnet die Tatsache, dass mehrere Prozesse nebeneinander laufen. Für den Nutzer sieht es so, als würden sie parallel verarbeitet. Auf einem einzigen
Prozessorkern werden nebenläufige Prozesse abwechselnd ausgeführt, auf mehreren können sie auch tatsächlich parallel ausgeführt werden. Wie dies im Detail geschieht, ist
Implementierungssache. Somit können verschiedene Teile eines Programms unabhängig
voneinander mit externen Systemen interagieren. Es geht also nicht in erster Linie darum,
ein Programm schneller auszuführen, sondern das Interaktionsverhalten eines Programms
zu definieren.
Ein typisches Beispiel für ein nebenläufiges System ist ein Betriebssystem. Dabei werden die einzelnen Programme nebenläufig ausgeführt. Dadurch blockiert die Nutzung
eines Programms nicht die Nutzung anderer Programme. Man kann also beispielsweise
gleichzeitig einen Film schauen und Musik hören. Ein anderes Beispiel ist ein Webserver,
der die Anfragen von Clients unabhängig voneinander beantworten soll. Auch um eine
Programmoberfläche bei rechenintensiven Aufgaben ansprechbar zu halten, bietet sich
Nebenläufigkeit an.
Nebenläufige Programme sind im Allgemeinen nichtdeterministisch. Das heißt, dass
mehrere Ausführungen des Programms verschiedene Ergebnisse produzieren können. Das
erschwert allerdings das Testen und Beweise über die Programmlogik. Bei der Parallelisierung geht es dagegen nur um die Beschleunigung eines Programms mithilfe mehrerer
Prozessorkerne. Für parallele Programmierung ist es also wünschenswert, dass das Programmiermodell deterministisch ist, ein Programm also unabhängig davon, ob es auf einem oder mehreren Prozessorkernen läuft, immer dasselbe Ergebnis produziert. Die später
vorgestellten parallelen Programmiermodelle in Haskell haben diese Eigenschaft.
Konservative und spekulative Parallelisierung Eine weitere Unterscheidung kann zwischen konservativer und spekulativer Parallelisierung getroffen werden ([PJ89]). Bei konservativer Parallelisierung werden nur solche Berechnungen parallel angestoßen, deren
31
Ergebnisse später in jedem Fall benötigt werden. Um die Summe einer langen Liste von
Zahlen zu berechnen, kann sie beispielsweise in mehrere Unterlisten geteilt werden, deren
Summen von parallelen oder nebenläufigen Prozessen berechnet werden. Am Ende müssen dann nur noch diese Teilsummen summiert werden. Dabei führt keiner der Prozesse
überflüssige Berechnungen durch.
Bei spekulativer Parallelisierung hingegen werden Teilberechnungen parallel angestoßen, die eventuell später verworfen werden, weil sich herausstellt, dass ihr Ergebnis
nicht gebraucht wird. Wird ein Ergebnis aber gebraucht, so ergibt sich ein Geschwindigkeitsvorteil durch Ausnutzung ansonsten brachliegender Rechenkapazität. Man spekuliert
also darauf, dass bestimmte Ergebnisse später verwendet werden können, und berechnet
sie parallel. So kann beispielsweise ein Suchbaum, in dem nur ein Ergebnispfad gefunden
werden soll, von mehreren parallelen Prozessen durchsucht werden, um einen solchen Ergebnispfad schneller zu finden. Ist der vom Hauptprozess durchsuchte Pfad schon solch ein
Ergebnispfad, bringt die parallele Suche nur dann einen Vorteil, wenn eine der parallelen
Berechnungen schneller ist. Ansonsten wurden sie nicht gebraucht. Wenn andererseits viele ergebnislose Pfade durchsucht werden müssen, bevor ein Ergebnispfad gefunden wird, so
beschleunigen die parallelen Berechnungen die Suche insgesamt. Das ist im Groben auch
die Idee für die Parallelisierung des Davis-Putnam-Algorithmus, wie sie im nächsten Kapitel vorgestellt wird. Eine andere Art der spekulativen Parallelisierung ist beispielsweise,
wie unter [PRV10] beschrieben, einen vordergründig sequentiellen Algorithmus so zu parallelisieren, dass das Ergebnis eines Zwischenschritts geraten wird und parallel mit diesem
geratenen Zwischenwert weitergerechnet wird, während das tatsächliche Zwischenergebnis
berechnet wird. Stimmt dieses tatsächliche Ergebnis mit dem geratenen überein, kann das
Ergebnis der parallelen Berechnung genutzt werden. Ansonsten muss es verworfen und
die Berechnung mit dem korrekten Zwischenergebnis wiederholt werden.
5.2 Parallelität und Nebenläufigkeit in Haskell
Die Möglichkeiten, Parallelität und Nebenläufigkeit in Haskell auszudrücken, sind im Wesentlichen die folgenden: Auf der einen Seite stehen die Eval-Monade und die auf sie
aufbauenden Auswertungsstrategien sowie die Par-Monade, die Parallelisierung in
rein funktionalen Kontexten ermöglichen und sich im Wesentlichen dadurch unterscheiden,
wie explizit Teilberechnungen auf mehrere Rechenkerne verteilt werden. Auf der anderen
Seite steht Concurrent Haskell, das Nebenläufigkeit in der IO-Monade bereitstellt.
Weitere Ansätze zur Parallelisierung in Haskell existieren, auf sie wird hier aber nicht
weiter eingegangen. Data Parallel Haskell stellt verschachtelte Daten-Parallelität (nested
data parallelism) bereit. Die Bibliothek befindet sich aber noch in einem experimentellen
Stadium. Weiterführende Informationen dazu finden sich im Haskell-Wiki1 . Die Bibliothek
Repa2 bietet reguläre parallele Arrays. Einen anderen Ansatz verfolgt Eden3 , das explizite
Prozesserstellung erlaubt, aber Kommunikation, Synchronisierung und Prozessverarbei1
http://www.haskell.org/haskellwiki/GHC/Data_Parallel_Haskell
http://repa.ouroborus.net/
3
http://www.mathematik.uni-marburg.de/~eden/
2
32
tung automatisiert. Die Speculation-Bibliothek4 bietet spekulative Parallelisierung, wie
sie in [PRV10] beschrieben wird.
Die folgenden Abschnitte über die Par-Monade und Concurrent Haskell beruhen auf
[Mar12].
5.2.1 Parallelisierung mit der Eval-Monade
Die Eval-Monade löst die ältere Parallelisierungs-API mit den Funktionen par und pseq
ab, die abgesehen von anderen Nachteilen spekulative Parallelisierung nur eingeschränkt
unterstützt (für Hintergründe hierzu siehe [MML+ 10]). Sie erlaubt es, parallele Berechnungen zu koordinieren, indem man die Reihenfolge der Auswertung von Ausdrücken
festlegt. Normalerweise entscheidet in Haskell ja der Compiler über die geschickteste Anordnung der Berechnungen. Die Funktion rpar dient dazu, eine Möglichkeit zur Parallelisierung anzuzeigen, rseq erzwingt die Auswertung des übergebenen Ausdrucks. Mit der
Funktion runEval wird ein Wert aus der Monade extrahiert, die enthaltenen Definitionen
also ausgeführt. Der folgende Algorithmus zeigt ein einfaches Beispiel, das die Ausdrücke
a und b parallel berechnet und das Ergebnis verwirft.
runEval $ do
x <- rpar a
y <- rpar b
rseq x
rseq y
return ()
Die Parallelisierung mithilfe der Eval-Monade ist semiexplizit: rpar x erzwingt nicht
die parallele Auswertung von x, es sorgt nur dafür, dass ein Spark für x erstellt wird.
Ist ein freier Prozessorkern vorhanden, wird ein Spark aus dem Spark-Pool entnommen
und auf diesem Prozessorkern ausgewertet. Nirgendwo (außer aus dem Spark-Pool) referenzierte Ausdrücke werden von der Garbage Collection gelöscht. Im Allgemeinen ist es
daher wichtig, Ausdrücke innerhalb der Monade an Variablen zu binden. rpar a legt eine
Referenz auf a in den Spark-Pool. Wird später nur ein Teilausdruck von a verwendet,
geht a verloren, wenn es nicht an eine Variable gebunden wurde, da der einzige Verweis
auf a im Spark-Pool liegt. Dadurch, dass rpar erst einmal nur einen Spark erstellt, wird
der Ausdruck möglicherweise nie parallel berechnet:
runEval $ do
x <- rpar a
y <- rseq b
return y
Würde dieser Algorithmus beispielsweise auf einem Kern ausgeführt, würde a nie ausgewertet werden, weil es im weiteren Programmverlauf nicht verwendet wird und zu keinem
Zeitpunkt ein freier Kern vorhanden ist. Der Spark würde am Ende von der Garbage
Collection entfernt werden. Ein nicht referenzierter Spark heißt dann garbage collected.
4
https://github.com/ekmett/speculation
33
Ein Spark wird auch dann nicht parallel berechnet, wenn der Ausdruck von einem anderen Teil des Programms schon ausgewertet wurde, bevor der Spark einem freien Prozessorkern zugewiesen werden konnte. Solch ein Spark wird als fizzled bezeichnet. Folgender
Algorithmus würde dieses Verhalten für a bei der Ausführung auf einem Prozessorkern
erzwingen; am Ende des Programms wäre a schon durch a + b ausgewertet.
runEval $ do
x <- rpar a
y <- rseq b
return (a + b)
Die dritte Variante, bei der ein Spark nicht parallel berechnet wird, ist der Fall, dass
der Ausdruck schon bei der Erstellung des Sparks bereits ausgewertet wurde. Solch ein
Spark wird als dud bezeichnet.
Durch diese Spark-Verwaltung kann das Laufzeitsystem Berechnungen dynamisch partitionieren. Solange genügend Sparks erstellt werden, kann ein Programm beliebig viele
Prozessorkerne nutzen. Werden zu viele Sparks erstellt, werden sie einfach nicht parallel ausgewertet. Der Verwaltungsaufwand für die Sparks ist zwar gering; trotzdem sollte
man aufpassen, genügend große Ausdrücke parallel berechnen zu lassen, sodass der Verwaltungsaufwand nicht größer wird als der Geschwindigkeitsvorteil durch die Parallelisierung.
rpar und rseq allein werten Ausdrücke nur bis zur schwachen Kopfnormalform (WHNF –
weak head normal form) aus, das heißt nur bis zum ersten Konstruktor. Für eine Liste bedeutet dies zum Beispiel, dass nur geprüft wird, ob die Liste mit [] oder x:xs konstruiert
wurde, also, ob sie leer ist oder nicht. Die einzelnen Elemente werden nicht ausgewertet.
Um einen Ausdruck vollständig, zur Normalform, auszuwerten, dient die Funktion force
(aus dem Paket Control.DeepSeq):
x <- rpar (force a)
erstellt beispielsweise einen Spark, der vollständig ausgewertet wird. Die Auswertungsstrategie rdeepseq hat denselben Zweck, bloß wird sie etwas anders verwendet.
Auswertungsstrategien Auswertungsstrategien sind eine Abstraktionsschicht auf der
Eval-Monade. Durch sie lassen sich der eigentliche Algorithmus und die Art der (parallelen) Auswertung voneinander trennen. Die oben genannten Funktionen rpar und rseq
sind Basisstrategien. Darüber hinaus gibt es r0, das einen Ausdruck gar nicht auswertet, und rdeepseq zur vollständigen Auswertung. Außerhalb der Eval-Monade kann eine
Strategie mit der Funktion using angewendet werden:
using :: a -> Strategy a -> a
x ‘using‘ s = runEval (s x)
Strategien sind Identitätsfunktionen, das heißt, sie verändern das Ergebnis eines Ausdrucks nicht (sie verpacken es nur in die Eval-Monade). Dadurch kann die Anwendung
von Strategien in ein Programm eingeführt werden, ohne dass sich das Ergebnis verändert. Es ist möglicherweise nur weniger definiert: Dadurch, dass x ‘using‘ s mehr von
34
x auswertet als x allein, terminiert das Programm vielleicht nicht oder bricht mit einem
Fehler ab, obwohl das ohne using nicht geschehen wäre ([Mar12], Abschnitt 2.2).
Strategien lassen sich einerseits mit dem Strategiekonkatenator dot, der analog zum
Funktionskonkatenator arbeitet, kombinieren. Um in einer parallelen Berechnung einen
Ausdruck vollständig auszuwerten, kombiniert man zum Beispiel rpar und rdeepseq:
x <- rpar ‘dot‘ rdeeqseq $ a. Auf der anderen Seite lassen sie sich zu komplexeren
Strategien zusammenbauen. parList ist beispielsweise eine Strategie, die eine andere
Strategie auf alle Elemente einer Liste anwendet und diese parallel auswertet:
parList :: Strategy a -> Strategy [a]
parList strat []
= return []
parList strat (x:xs) = do
x’ <- rpar (x ‘using‘ strat)
xs’ <- parList strat xs
return (x’:xs’)
Damit lässt sich einfach eine Funktion schreiben, die eine beliebige Funktion parallel
auf eine Liste anwendet, ein paralleles map:
parMap f xs = map f xs ‘using‘ parList rseq
parMap verwendet die normale map-Funktion wieder und trennt den eigentlichen Algorithmus auf der linken Seite von using von der Auswertungsstrategie auf der rechten
Seite. Das funktioniert, weil Listen verzögert ausgewertet werden. Nicht alle Algorithmen
lassen sich aber auf diese Weise parallelisieren ([MML+ 10], Abschnitt 2.1).
5.2.2 Die Par-Monade
Eine Möglichkeit, die parallele Ausführung von Programmen expliziter zu definieren, bietet die Par-Monade. Dabei können wie im später beschriebenen Concurrent Haskell Prozesse erstellt werden und Daten zwischen ihnen ausgetauscht werden. Allerdings finden die
Berechnungen nicht in der IO-Monade statt, die Determiniertheit des Programms bleibt
erhalten.
Parallele Berechnungen werden innerhalb von runPar angestoßen. Dies geschieht mit
der Funktion fork. Die Kommunikation zwischen parallelen Berechnungen findet über
Objekte des Typs IVar statt. Die Funktion new erstellt eine neue, leere IVar. Mit put
wird ein Wert in eine solche Variable geschrieben, mit get ihr Inhalt gelesen. get wartet,
bis sich ein Wert in der Variable befindet. Mehrere put-Operationen auf eine IVar sind
nicht erlaubt – der Versuch, dies zu tun, führt zu einem Fehler. Der folgende Algorithmus
zeigt ein einfaches Beispiel, das die Ausdrücke a und b parallel auswertet und deren
Summe zurückgibt:
runPar $ do
ia <- new
ib <- new
fork (do put ia a)
fork (do put ib b)
35
a’ <- get ia
b’ <- get ib
return (a’ + b’)
Eine abstraktere Methode, um eine Berechnung innerhalb der Monade parallel anzustoßen, bietet die Funktion spawn. spawn gibt eine IVar zurück, die das Ergebnis enthält,
sobald die Berechnung abgeschlossen ist.
spawn :: NFData a => Par a -> Par (IVar a)
spawn p = do
i <- new
fork (do x <- p; put i x)
return i
Das obige Beispiel ließe sich damit einfacher schreiben:
runPar $ do
ia <- spawn (return a)
ib <- spawn (return b)
a’ <- get ia
b’ <- get ib
return (a’ + b’)
Mit spawn lässt sich auch einfach ein paralleles map implementieren:
parMapM :: NFData b => (a -> Par b) -> [a] -> Par [b]
parMapM f as = do
ibs <- mapM (spawn . f) as
mapM get ibs
Im Unterschied zum parMap aus der Eval-Monade wartet diese Funktion auf die Auswertung aller Listenelemente. Ausdrücke werden in der Par-Monade im Gegensatz zur
Eval-Monade standardmäßig vollständig ausgewertet ([MNJ11]).
Ein Aufruf von runPar ist deutlich teurer als ein runEval-Aufruf, weil eine neue
Scheduler-Instanz mit einem Worker-Thread pro Prozessor erstellt wird ([Mar12], Abschnitt 2.3). Deswegen ist hier besonders darauf zu achten, nicht zu kleine Berechnungen
abzuspalten.
Die von fork erstellten Prozesse werden nebenläufig berechnet. Sie werden in jedem
Fall zu Ende geführt, egal ob freie Prozessorkerne vorhanden sind oder nicht ([MNJ11]).
Es gibt keine Möglichkeit, gestartete parallele Berechnungen abzubrechen. Dadurch ist
spekulative Parallelisierung, also das Erstellen von parallelen Berechnungen, die eventuell
nicht gebraucht werden, nur eingeschränkt möglich. Es gibt allerdings eine Modifikation
der Par-Monade, die Unterstützung für das Abbrechen von Parallelberechnungen bietet.
Sie wird unter [Pet11] beschrieben. Dabei handelt es sich allerdings nicht um ein reguläres
Haskell-Paket. Zudem beruht die Modifikation auf einer alten Version der Par-Bibliothek.
36
5.2.3 Nebenläufigkeit mit Concurrent Haskell
Die bisher beschriebenen Methoden erlauben es, parallele Haskell-Programme zu schreiben. Die Par-Monade arbeitet zwar mit Nebenläufigkeit, es gibt jedoch keine Seiteneffekte.
Sie dient nur zur Parallelisierung eines Programms. Concurrent Haskell ermöglicht tatsächlich nebenläufige Programme mit Seiteneffekten.
Die Arbeit findet bei Concurrent Haskell deswegen in der IO-Monade statt. Ein neuer
Prozess wird mit forkIO erstellt. Dabei wird die übergebene IO-Berechnung nebenläufig zum aktuellen Prozess ausgeführt und eine Identifikationsnummer für den Prozess
zurückgegeben. Die erstellten Prozesse werden vom Laufzeitsystem verwaltet; sie sind
leichtgewichtiger als Betriebssystemprozesse. Mit forkOS kann aber auch explizit ein Betriebssystemprozess erstellt werden. Prozesse lassen sich mit der Funktion killThread
über ihre Identifikationsnummer abbrechen.
Die Prozesse können über MVars kommunizieren. newEmptyMVar erstellt eine neue, leere
MVar, newMVar eine neue mit dem übergebenen Wert als Inhalt. Die Funktion putMVar legt
einen Wert in einer MVar ab. Ist diese schon gefüllt, wartet der aktuelle Prozess darauf,
dass sie geleert wird. Einen Wert aus einer MVar entnimmt takeMVar und wartet, bis diese
gefüllt wird, wenn sie leer ist.
Das folgende Beispiel lädt zwei Webseiten nebenläufig herunter und gibt deren Inhalt
zurück. getURL liefert in diesem Fall den Inhalt der Webseite unter der angegebenen URL.
Die mit forkIO erstellten nebenläufigen Prozesse laden jeweils eine Webseite herunter und
legen den Inhalt in je einer MVar ab. Der Inhalt der MVars wird gelesen, sobald er verfügbar
ist, und als Tupel zurückgegeben.
do
m1 <- newEmptyMVar
m2 <- newEmptyMVar
forkIO $ do
r <- getURL "http://www.wikipedia.org/wiki/Shovel"
putMVar m1 r
forkIO $ do
r <- getURL "http://www.wikipedia.org/wiki/Spade"
putMVar m2 r
r1 <- takeMVar m1
r2 <- takeMVar m2
return (r1,r2)
Analog zu spawn aus der Par-Monade bietet sich auch in Concurrent Haskell eine Abstraktion an, die es erlaubt, eine nebenläufige Berechnung zu starten und deren Ergebnis
später zu lesen. Die Funktion async startet einen neuen Prozess für eine Berechnung
und gibt ein Objekt des Typs Async zurück, das schließlich das Ergebnis der Berechnung
enthält. Das kann mit wait gelesen werden. Außerdem enthält Async die Identifikati-
37
onsnummer des gestarteten Prozesses, um diesen mit der Funktion cancel abbrechen zu
können:
async :: IO a -> IO (Async a)
async io = do
m <- newEmptyMVar
t <- forkIO $ do r <- io; putMVar m r
return (Async t m)
wait :: Async a -> IO a
wait (Async t m) = readMVar m
readMVar :: MVar a -> IO a
readMVar m = do
a <- takeMVar m
putMVar m a
return a
Damit lässt sich das obige Beispiel einfacher schreiben:
do
a1 <- async $ getURL "http://www.wikipedia.org/wiki/Shovel"
a2 <- async $ getURL "http://www.wikipedia.org/wiki/Spade"
r1 <- wait a1
r2 <- wait a2
return (r1,r2)
Hier vorgestellt wurden nur die grundlegenden Funktionen von Concurrent Haskell. Eine
ausführliche Einführung, die unter anderem auch asynchrone Exceptions und Software
Transactional Memory behandelt, findet sich in [Mar12].
Futures Die async-Funktion bietet einen sogenannten expliziten Future an. Der Rückgabewert der nebenläufigen Berechnung muss explizit verlangt werden und an der Stelle
im Code, wo dies geschieht, muss auf das Ende dieser Berechnung gewartet werden. Dagegen geschieht die Rückgabe des Ergebnisses bei einem impliziten Future implizit, also
erst, wenn es tatsächlich (aufgrund der Datenabhängigkeiten) gebraucht wird. In Haskell
kann ein solcher impliziter Future mithilfe der Funktion unsafeInterleaveIO implementiert werden, die eine Berechnung innerhalb der IO-Monade verzögert, bis das Ergebnis
gebraucht wird. Diese Unterscheidung wird in [SSS11a] genauer untersucht.
future :: IO a -> IO a
future act = do
result <- newEmptyMVar
forkIO (act >>= putMVar result r)
x <- unsafeInterleaveIO (takeMVar result)
return x
38
6 Parallelisierung des
Davis-Putnam-Algorithmus
6.1 Ansatz
Um sich zu veranschaulichen, wie der Davis-Putnam-Algorithmus parallelisiert werden
kann, bietet es sich an, einen Baum aufzuzeichnen, der die Ausführung darstellt. Abbildung 6.1 zeigt den Entscheidungsbaum für Beispiel 4.1.1, die Klauselmenge
{{p, ¬q}, {p, ¬q, r}, {p, q, ¬r}, {¬p, q}, {¬p, ¬q, r}, {¬p, ¬q, ¬r}}
Jeder Knoten stellt einen Schritt des Algorithmus dar und welches Literal dabei gewählt
wurde. Hat ein Knoten zwei Kinder, so handelt es sich um die Anwendung von Regel III;
der Fall, dass das Literal auf wahr gesetzt wird, steht auf der linken Seite, der Fall, dass es
auf falsch gesetzt wird, auf der rechten. Wir sprechen im Folgenden einfach vom positiven
bzw. negativen Pfad. Ein Knoten mit nur einem Kind ist eine Anwendung von Regel I
(oder II, wobei diese in der Implementierung ja außen vor bleibt), da dabei nur eine
Belegung verwendet wird. Der Baum hängt natürlich vom konkreten Verfahren zur Wahl
der Literale ab. Ein Blattknoten stellt den Rückgabewert des letzten rekursiven Aufrufs
im zugehörigen Pfad dar. Ist mindestens ein Blattknoten wahr, so ist die Klauselmenge
erfüllbar.
Die Idee ist nun, an jedem Verzweigungspunkt, also wenn ein Literal entweder auf wahr
oder falsch gesetzt werden kann, beide Möglichkeiten parallel zu berechnen. Es wird also
spekulativ parallelisiert, da ja nicht bekannt ist, welcher und ob überhaupt ein Pfad ein
Ergebnis liefern wird. Hierbei werden zwei grundsätzliche Ansätze untersucht:
Ansatz I Es wird ein neuer Prozess für den negativen (oder positiven) Pfad abgespalten
und im aktuellen Prozess der positive (bzw. negative) Pfad berechnet.
Abbildung 6.1: Entscheidungsbaum für die Klauselmenge aus Beispiel 4.1.1
p
q
q
r
¬r
¬r
falsch
falsch
wahr
39
Ansatz II Es werden zwei parallele Prozesse für beide Alternativen abgespalten und das
Ergebnis des schneller berechneten Pfades genommen. Liefert der schnellere Pfad
keine Lösung, wird das Ergebnis des anderen zurückgeliefert.
Scheduling Zudem stellt sich die Frage, wie die Prozesse Rechenkapazität zugewiesen
bekommen. Schauen wir uns an, wie die verschiedenen Methoden in Haskell arbeiten:
Die Eval-Monade nutzt Parallelität. Die Prozesse werden etwa in der Reihenfolge, in
der sie erstellt werden, abgearbeitet. Genauer gesagt wird ein Haskell Execution Context
(HEC) pro Prozessorkern verwaltet, der jeweils einen eigenen Spark-Pool hat. Ein HEC
entnimmt Sparks zuerst aus seinem eigenen Pool, bevor er Sparks von anderen HECs
»klaut« ([MPJS09]). Die Spark-Pools werden dabei so verwaltet, dass immer der älteste
Spark entnommen und dann soweit ausgewertet wird, wie es die verwendete Auswertungsstrategie definiert, bevor der nächste an der Reihe ist.
Concurrent Haskell vewaltet die Prozesse nebenläufig so, dass sie abwechselnd kurze
Zeitfenster zugeteilt bekommen1 . Dadurch sollte sich eine Art Breiten- statt einer Tiefensuche im Suchbaum ergeben. Das könnte für bestimmte Formeln auch von Vorteil sein,
wenn der Algorithmus auf nur einem Kern berechnet wird.
Die Par-Monade arbeitet mit Nebenläufigkeit. Im Unterschied zu den anderen Varianten ist hier anpassbar, wie die Prozesse Rechenzeit zugeteilt bekommen. Es existieren
verschiedene eingebaute Scheduler, man kann aber auch eigene Implementierungen einbinden. Da die Implementierung in der Par-Monade aber aus Zeitgründen nicht näher
untersucht wurde, sei für die Details auf [MNJ11] verwiesen.
Parallelisierungstiefe Eine weitere Frage ist außerdem, bis zu welcher Tiefe des Suchbaums parallelisiert werden soll; ab wann also auf den sequentiellen Algorithmus zurückgegriffen werden soll, damit nicht zu kleine Berechnungen abgespalten werden. Wird die
parallele Berechnung nämlich zu schnell fertiggestellt, geht der Vorteil durch die Parallelisierung dadurch verloren, dass der Verwaltungsaufwand für das Erstellen und Koordinieren des neuen Prozesses mit den anderen Prozessen zu hoch ist (bzw. der Aufwand für
die Spark-Verwaltung). Und werden zu viele zu kleine Berechnungen abgespalten, könnte
sich das insgesamt negativ auf die Laufzeit auswirken.
Eine angemessene Parallelisierungstiefe hängt natürlich von der zu prüfenden Formel
ab. Je höher die Verzweigungstiefe des Suchbaums, desto höher kann diese sein, sodass
die parallele Berechnung immer noch aufwendig genug ist. Ist die Größe der Formel in
etwa bekannt, könnte man sie von Hand setzen; zum Beispiel wenn viele ähnliche Formeln
getestet werden. Es wurde nicht näher untersucht, wie man automatisch in Abhängigkeit
von der Eingabeformel eine gute Tiefe ermitteln könnte.
1
Wobei es Randfälle gibt, in denen einzelne Prozesse andere länger aufhalten können; siehe
die API-Dokumentation unter http://www.haskell.org/ghc/docs/latest/html/libraries/base/
Control-Concurrent.html.
40
Algorithmus 6.1 Grundgerüst der parallelen Implementierungen
davisPutnamSatVariante :: Int -> KlauselMenge -> [Literal] -> [Literal]
davisPutnamSatVariante _ [] loesung = loesung
davisPutnamSatVariante threshold klauselMenge loesung
| [] ‘elem‘ klauselMenge = []
| otherwise =
case findUnit klauselMenge of
Just u ->
davisPutnamSatVariante threshold (resolveUnit u klauselMenge)
(u:loesung)
Nothing ->
let minUnit = findBestLiteral klauselMenge
positivePath = davisPutnamSatVariante (threshold - 1)
(resolveUnit minUnit klauselMenge)
(minUnit:loesung)
negativePath = davisPutnamSatVariante (threshold - 1)
(resolveUnit (- minUnit) klauselMenge)
((- minUnit):loesung)
in
if threshold > 0 then
-- Parallelisierung
else
case positivePath of
[] -> negativePath
xs -> xs
6.2 Implementierungen
Der komplette Code dieser Arbeit kann unter http://www-stud.informatik.uni-frankfurt.
de/~tilber/davis-putnam heruntergeladen werden. Dort befinden sich auch Hinweise
zur Installation und Ausführung. Zum Kompilieren und Testen wurde GHC 7.4.2 verwendet.
Implementiert und näher untersucht wurden grundsätzlich vier parallele Varianten des
Algorithmus. Ansatz I wurde einmal mithilfe der Eval-Monade und in zwei Varianten mit
implizitem und explizitem Future mit Concurrent Haskell implementiert, wobei immer
der positive Pfad Vorrang hat. Ansatz II wurde in einer Variante in Concurrent Haskell
implementiert.
Alle parallelen Varianten basieren auf der sequentiellen Implementierung, wie sie in
Kapitel 4.2 vorgestellt wurde. Algorithmus 6.1 zeigt das Grundgerüst. davisPutnamSatVariante steht hierbei für den Namen der Variante. Die Paralellisierungstiefe wird als
erstes Argument übergeben; der Typ ist also
41
Int -> KlauselMenge -> [Literal] -> [Literal]
sodass die Funktion mit an sie übergebener Parallelisierungstiefe (threshold) denselben
Typ wie die sequentielle Variante des Algorithmus hat:
KlauselMenge -> [Literal] -> [Literal]
davisPutnamSatEvalt 10 ist also beispielsweise die »Untervariante« von davisPutnamSatEvalt, die bis zur Suchbaumtiefe 10 parallelisiert. Bei jeder Verzweigung (der
Nothing-Fall) wird in den rekursiven Aufrufen das threshold-Argument um 1 reduziert,
sodass bei der if-Abfrage irgendwann der else-Fall greift und nicht mehr parallelisiert
wird.2 In dem Fall, dass vorher eine Unit-Klausel gefunden wurde, wird threshold nicht
reduziert. Dadurch lässt sich besser steuern, wie viele parallele Berechnungen erzeugt
werden.
Bei den Varianten, die mit Concurrent Haskell implementiert wurden, weicht das Grundgerüst leicht ab, da sie in der IO-Monade arbeiten. Der Typ aller dieser Varianten ist
Int -> KlauselMenge -> [Literal] -> IO [Literal]
Der Rückgabewert ist also IO [Literal] statt [Literal]. Außerdem muss das Ergebnis jeweils noch mit return in die IO-Monade verpackt werden und im sequentiellen Part
am Ende des Codes muss positviePath erst aus der Monade entpackt werden, bevor der
Inhalt geprüft werden kann.
Wir verwenden im Folgenden Kurznamen für die Varianten, die sich aus dem Suffix
hinter davisPutnamSat ergibt: Heißt die Funktion im Code zum Beispiel davisPutnamSatEvalt, nennen wir sie hier kurz Evalt. Die sequentielle Variante des Algorithmus
bezeichnen wir auch kurz als Seq.
6.2.1 Eval-Monade
Da die Eval-Monade im puren (also seiteneffektfreien) Kontext arbeitet, lässt sich Ansatz II nicht direkt umsetzen, denn er würde bei mehreren Ausführungen des Algorithmus
möglicherweise unterschiedliche Ergebnisse liefern; also unterschiedliche erfüllende Belegungen für dieselbe Formel. Zwar könnte man die Implementierung dahingehend ändern,
dass sie nur einen Wahrheitswert zurückgibt, der sagt, ob die Formel erfüllbar ist oder
nicht. Dann wäre zwar die referentielle Transparenz bewahrt. Aber der Compiler weiß
nicht, dass die zwei unterschiedlichen Funktionsaufrufe am Ende immer zum selben Ergebnis führen, egal welche der beiden abgespaltenen Berechnungen zuerst fertig ist. Es
gibt keine Möglichkeit in Haskell, den schneller ausgewerteten zweier Sparks zu erkennen.
Die Variante, die mithilfe der Eval-Monade implementiert wurde, nennt sich Evalt.
Der Code für die Parallelisierung lautet wie folgt:
2
Hier fiel dem Autor zu spät auf, dass die darauf folgende case-Abfrage besser durch einen Aufruf der
sequentiellen Variante davisPutnamSat ersetzt worden wäre, wodurch die if-Abfrage in der Folge
bei der Ausführung wegfallen würde und das threshold-Argument nicht mehr mitgeführt werden
müsste. Diese Optimierung macht aber keinen messbaren Unterschied, da sie selbst bei nur noch kurzen
Teilformeln nur einen Bruchteil der Schritte ausmacht bzw. das zusätzliche Argument die Ausdrücke
nur unwesentlich vergrößert.
42
runEval $ do
x <- rparWith rdeepseq negativePath
return (case positivePath of
[] -> x
xs -> xs)
Der Ausdruck rparWith rdeepseq negativePath erzeugt einen Spark für den negativen Pfad (und stellt mit rdeepseq sicher, dass dieser vollständig ausgewertet wird).
Anschließend wird der positive Pfad durch die case-Abfrage ausgewertet. Liefert dieser
nur die leere Liste, also keine erfüllende Belegung, wird der Spark-Ausdruck für den negativen Pfad abgefragt. Implizit geschieht Folgendes: Wurde er noch nicht parallel ausgewertet, wird das nun getan (nicht parallel); ansonsten wird das schon berechnete Ergebnis
verwendet.
Da Sparks so leichtgewichtig sind (im Vergleich zu den Prozessen, die die Par-Monade
oder Concurrent Haskell erzeugen), könnte eine unbeschränkte Parallelisierungstiefe interessant sein. Deswegen wurde auch eine Variante ohne beschränkte Parallelisierungstiefe
implementiert. Sie heißt einfach Eval. Bei ihr kann natürlich die if-Abfrage, wie sie in
Algorithmus 6.1 vorgestellt wurde, wegfallen, außerdem muss die Parallelisierungstiefe
nicht als Argument übergeben werden. Damit beträgt der Unterschied gegenüber der
sequentiellen Variante kaum mehr als zwei Codezeilen.
6.2.2 Concurrent-Haskell-Varianten
Ansatz I wurde einmal mit implizitem und einmal mit explizitem Future implementiert.
Dazu kommt eine Variante nach Ansatz II.
Impliziter Future
Con ist die Variante mit implizitem Future. Sie parallelisiert wie folgt:
do
(tid, np) <- future negativePath
pp <- positivePath
case pp of
[] -> return np
xs -> killThread tid >> return xs
Dabei ist future wie in Kapitel 5.2.3 implementiert, nur dass zusätzlich noch die Prozessnummer (tid) zurückgeliefert wird, um den Prozess abbrechen zu können, wenn im
positiven Pfad ein Ergebnis gefunden wurde. Nach dem Erstellen des nebenläufigen Prozesses für den negativen Pfad wird der positive aus der Monade entpackt, um seinen Wert
abfragen zu können.
Eine Abwandlung dieser Variante entstand unbeabsichtigt, weil der Autor die ersten
zwei Zeilen des Parallelisierungscodes vertauscht hatte:
do
pp <- positivePath
(tid, np) <- future negativePath
43
case pp of
[] -> return np
xs -> killThread tid >> return xs
Die Idee ist ja eigentlich, dass zuerst der negative Pfad abgespalten wird und dann
der positive berechnet wird, sodass beide Berechnungen parallel stattfinden können. Da
die IO-Monade die Berechnungen ordnet, scheint die Konsequenz dieses Codes zu sein,
dass der positive Pfad bei Abspaltung des negativen schon berechnet wurde. Damit fände
keine Parallelisierung statt. Tatsächlich funktioniert es aber, wenn auch nicht exakt wie
die erste Variante! Deswegen wurde die vermeintlich fehlerhafte Implementierung in die
Tests miteinbezogen. Sie trägt den Namen Con’.
Expliziter Future Die Variante mit explizitem Future, Con2, unterscheidet sich von der
mit implizitem Future dadurch, dass nicht das Ergebnis, sondern eine MVar zurückgegeben
wird, die das Ergebnis aufnimmt, wenn es berechnet wurde:
do
(tid, npvar) <- future’ negativePath
pp <- positivePath
case pp of
[] -> takeMVar npvar >>= return
xs -> killThread tid >> return xs
future’ ist der explizite Future, der im Unterschied zum impliziten nicht selbst takeMVar
ausführt (zusammen mit unsafeInterleaveIO zur Verzögerung des Lesens), sondern dies
dem Programmierer überlässt.
Warum nicht async aus Kapitel 5.2.3? Um die beiden Future-Varianten besser vergleichen zu können, sollte möglicher Overhead durch die Abstraktion ausgeschlossen werden.
Eine Variante, die die Bibliothek async3 , die eine Erweiterung gegenüber der Implementierung aus Kapitel 5.2.3, das diesen Satz unnötig tief verschachtelt, darstellt, verwendet,
wurde nicht näher untersucht, weil sie im Vergleich zur anderen Variante ein schlechteres
Laufzeitverhalten zeigte, wenn zu viele Prozesse erzeugt wurden.
Ansatz II Der zweite Ansatz für die Parallelisierung wird durch Amb implementiert. Es
werden zwei nebenläufige Prozesse für die beiden möglichen Pfade erstellt, die beide auf
dieselbe MVar schreiben. Hierbei wird das Verhalten der MVar-Operationen ausgenutzt:
takeMVar wartet solange, bis die MVar ein Ergebnis enthält, und entnimmt das Ergebnis,
lässt also eine leere MVar zurück. putMVar schreibt nur in eine leere MVar. Ist sie bereits
gefüllt, wartet putMVar, bis sie leer ist.
do
pvar
tidp
tidn
3
<- newEmptyMVar
<- forkIO (positivePath >>= putMVar pvar)
<- forkIO (negativePath >>= putMVar pvar)
http://hackage.haskell.org/package/async
44
first <- takeMVar pvar
case first of
[] -> unsafeInterleaveIO (takeMVar pvar)
>>= return
xs -> killThread tidp >> killThread tidn
>> return xs
Der schnellere Kindprozess legt in pvar sein Ergebnis ab, das der Hauptprozess mit
takeMVar liest. Der langsamere Kindprozess kann sein Ergebnis erst danach dort ablegen. Ist das Ergebnis des schnelleren Prozesses die leere Liste, so liest der Hauptprozess
ein zweites Mal die MVar, die nun das Ergebnis des langsameren Prozesses enthält. Im
Falle, dass der schnellere schon eine erfüllende Belegung liefert, braucht eigentlich nur der
langsamere abgebrochen zu werden – da jedoch nicht bekannt ist, welcher das ist, wird
einfach versucht, beide abzubrechen. Einer der beiden Aufrufe hat dann einfach keine
Auswirkung.
6.2.3 Par-Monade
Eine Implementierung mithilfe der Par-Monade wurde verworfen, da die Par-Monade wie
oben angesprochen keine spekulative Parallelisierung unterstützt und alle abgespaltenen
Prozesse zu Ende berechnet, bevor der Algorithmus sein Ergebnis zurückliefern kann. Damit verschenkt sie in jedem Fall unnötig Potential. Bei Formeln, die zu einem sehr großen
kompletten Suchbaum führen (den man erhält, indem man alle erfüllenden Belegungen
sucht), eine erfüllende Belegung aber schnell gefunden werden kann (der Suchbaum bis
zum ersten wahr-Blattknoten also im Vergleich zum kompletten sehr klein ist), hat sie
ein sehr schlechtes Laufzeitverhalten. Dazu wurden nur ein paar kurze Test durchgeführt.
Teilweise war sie um etwa den Faktor 30 schlechter als der sequentielle Algorithmus.
Ebenso wurde eine Implementierung verworfen, die die unter [Pet11] beschriebene Modifikation der Par-Monade verwendet, um laufende Berechnungen abzubrechen. Sie ergab
keine wesentlichen Verbesserungen gegenüber der ersten Implementierung. Strukturell ist
sie genauso wie die Concurrent-Haskell-Varianten mit explizitem Future aufgebaut, nur,
dass Prozesse mit einem CancelToken erstellt werden, um sie abbrechen zu können. Das
Problem dabei dürfte sein, dass nur der direkt abgespaltene Prozess abgebrochen wird,
nicht aber dessen Kindprozesse. Das müsste sich laut [Pet11] dadurch beheben lassen,
dass alle Prozesse mit demselben CancelToken erstellt werden. Aus Zeitgründen wurde
der Versuch, die Implementierung dahingehend zu ändern, aber nicht unternommen.
Ansatz II lässt sich mit der Original-Par-Monade, wie auch mit der Modifikation, nicht
wie oben in Concurrent Haskell implementieren. Denn mehrere Schreibzugriffe auf eine
IVar, das Analogon der Par-Monade zu MVar, führen zu einem Laufzeitfehler. Die Modifikation stellt newBlocking bereit, das eine IVar erzeugt, die zwar das mehrfache Schreiben
erlaubt. Sie blockiert allerdings den langsameren Prozess, sodass er in eine Endlosschleife
gerät.
45
6.2.4 Anmerkungen
Alle Concurrent-Haskell-Varianten benutzen je eine MVar, um das Ergebnis des alternativen Pfades aufzunehmen. Diese werten den Ausdruck, der in sie geschrieben wird, nicht
aus4 . Landet also ein unausgewerteter Ausdruck in einer MVar, muss der lesende Prozess
ihn auswerten. Heißt das nun, dass die vorgestellten Varianten gar nicht parallelisieren
werden? Die Antwort lautet nein, denn die Ausdrücke, die hier in die MVar geschrieben
werden, wurden schon vollständig ausgewertet. Sie werden nämlich erst aus der IO-Monade
»befreit«. Das führt aber zur vollständigen Auswertung, da ein unmonadischer Ausdruck
erst mit einem Ergebnis des Algorithmus verfügbar ist. Ähnlich verhält es sich auch in der
Eval-Monade. Bei dieser Variante würde ein einfaches rpar (ohne rdeepseq) zur SparkErstellung ausreichen, denn die schwache Kopfnormalform, bis zu der rpar auswertet,
ist hier identisch mit der Normalform. Um den obersten Konstruktor der Ergebnisliste
festzustellen, muss sie nämlich vollständig ausgewertet werden. Denn erst dann ist bekannt, ob es eine erfüllende Belegung gibt oder nicht. Die Lösung wird ja nicht nach und
nach erzeugt, sondern komplett von einem rekursiven Aufruf auf der untersten Ebene des
Suchbaums (und dann nach oben durchgereicht).
In der Eval-Monade muss nicht explizit dafür gesorgt werden, dass Sparks entfernt werden; sie werden, wie oben schon erwähnt, einfach von der Garbage Collection aufgeräumt,
wenn sie unerreichbar sind. Bei den Concurrent-Haskell-Varianten sollte das Abbrechen
der Alternativpfade eigentlich auch nicht nötig sein, weil unerreichbare Prozesse auch hier
von der Garbage Collection entfernt werden5 . Aber bis die Garbage Collection eingreift,
könnten sie noch Rechenkapazität stehlen. Deswegen könnte es sogar sinnvoll sein, auch
ihre Kindprozesse abzubrechen, da killThread nur den angegebenen Prozess abbricht,
nicht dessen Kindprozesse. Ob das einen messbaren Unterschied macht, wurde nicht untersucht.
6.3 Bezug zu existierenden Ansätzen
Die vorgestellten Implementierungen benötigen alle nur geringe Modifikationen gegenüber
dem sequentiellen Code. Vor allem mit der Eval-Monade sind die Anpassungen minimal.
Bevor wir uns anschauen, wie sie sich in der Praxis schlagen, stellen wir einen kurzen
Vergleich mit den Systemen aus [BS96] und [ZB96] an, um ein paar Implikationen der
verwendeten Haskell-Methoden zu verdeutlichen.
Beide nutzen im Prinzip Ansatz II für die Parallelisierung, es wird also immer das
Ergebnis der schnelleren parallelen Berechnung zuerst geprüft. Auch wenn Ansatz I (abgesehen von Verwaltungsoverhead) niemals schneller sein sollte als Ansatz II, wurde er
hier aufgrund der funktionalen Natur von Haskell stärker berücksichtigt.
[BS96] stellt eine Implementierung für ein Mehrprozessorsystem vor und beschäftigt sich
auch damit, wie die Prozessoren untereinander kommunizieren und Teilprobleme austauschen. Dabei wird der Arbeitsaufwand abgeschätzt, um einerseits die Arbeit angemessen
4
5
http://www.haskell.org/ghc/docs/latest/html/libraries/base/Control-Concurrent.html
http://www.haskell.org/haskellwiki/Lightweight_concurrency
46
zu verteilen, andererseits, um irgendwann auf den sequentiellen Algorithmus umzusteigen.
Diese Abschätzung αn hängt von der Anzahl n der in der Formel vorkommenden Variablen
ab und benutzt eine von Hand optimierte Konstante α, die je nach Formelklasse variiert
wird. Aufgrund der verwendeten Datenstruktur kann n effizient bestimmt werden. Gerade bei einem ungleichmäßigen Suchbaum sollte solch eine Abschätzung besser als eine für
den ganzen Suchbaum vorgegebene Parallelisierungstiefe sein, außerdem muss sie nicht in
Abhängigkeit der Formelgröße von Hand angepasst werden. In unserer aktuellen Implementierung müsste aber in jedem Schritt die gesamte (Teil-) Klauselmenge durchgegangen
werden, um die Anzahl der noch nicht belegten Variablen zu bestimmen. Diese oder eine ähnlich gute Abschätzung effizient zu implementieren, könnte eine Untersuchung wert
sein.
Während in [BS96] ein Algorithmus für die Verteilung des Arbeitsaufwands zwischen
den Prozessoren entwickelt wird, wird diese Verwaltung bei den hiesigen Implementierungen automatisch durch Haskells Laufzeitsystem vorgenommen.
In [BS96] wie in [ZB96] werden während der Berechnung nicht ganze Klauselmengen,
sondern nur Listen von Teilbelegungen ausgetauscht. Im Unterschied dazu übergeben die
Haskell-Implementierungen immer die gesamte (Teil-) Klauselmenge, für jeden rekursiven
Schritt wird eine Kopie des gesamten Ausdrucks erzeugt.
47
7 Experimentelle Untersuchung
Die verschiedenen parallelen Implementierungen wurden nun auf ihre Geschwindigkeit im
Vergleich zur sequentiellen Variante untersucht. Insbesondere auch darauf, wie sich die
Parallelisierungstiefe auf die Geschwindigkeit der einzelnen Varianten auswirkt.
7.1 Testaufstellung
Als Testformeln dienten zufällig erzeugte Klauselmengen, in denen jede Klausel genau
3 Literale hat, sogenannte 3-SAT-Formeln 1 . Alle Formeln stammen aus dem SATLIBProjekt ([HS00]). Sie liegen im DIMACS-Format unter http://www.cs.ubc.ca/~hoos/
SATLIB/benchm.html vor. In [HS00] befindet sich eine genauere Beschreibung, wie die
Formeln erzeugt wurden. Das Verhältnis von Klauseln zu Variablen wurde immer so gewählt, dass fast alle zufällig erzeugten Formeln, deren Klausel-Variablen-Verhältnis kleiner
als α ist, erfüllbar sind, während für größere Verhältnisse fast alle Formeln unerfüllbar
sind. α variiert leicht in Abhängigkeit von der Anzahl der Variablen. Es ist im allgemeinen
etwa 4,26, für kleinere Formeln aber größer.
Die getesteten Klauselmengen haben 125, 150 und 200 Variablen, 538, 645 bzw. 860
Klauseln und sind nach erfüllbaren und unerfüllbaren Formeln getrennt. Nach diesen
Eigenschaften sind die Gruppen benannt: uf125-538, uf150-645 und uf200-860 sind die
erfüllbaren Formelgruppen, uuf125-538, uuf150-645 und uuf200-860 die entsprechenden
unerfüllbaren. Es wurden jeweils die ersten 20 Formeln der erfüllbaren, jeweils die ersten
10 der unerfüllbaren Formelgruppen getestet.
Die Tests wurden auf einem System mit zwei Vierkern-Prozessoren vom Typ AMD
Opteron 2356, also insgesamt acht Rechenkernen, und 16 GB Arbeitsspeicher ausgeführt.
Den automatisierten Benchmarkläufen diente die Haskell-Bibliothek Criterion2 als Grundlage. Die einzelnen Messungen werden damit mehrmals wiederholt und jeweils der Mittelwert bestimmt. Dazu liefert Criterion eine Abschätzung, wie genau die Messergebnisse
sind. Die Messungen für die Formelgruppen mit 125 und 150 Variablen wurden je 20-mal,
für die mit 200 je dreimal wiederholt. Alle Ergebnisse weisen eine hohe Varianz auf und
sind deshalb mit Vorsicht zu genießen. In den Messdaten sind auch einige merkwürdige
Ausreißer vorhanden, die in Einzelprüfungen so nicht reproduziert werden konnten. Hier
müssten ausführlichere Messungen angeschlossen werden, die die Ergebnisse verifizieren.
1
Auch das 3-SAT-Problem ist NP-vollständig; somit lässt sich das allgemeine Erfüllbarkeitsproblem der
Aussagenlogik darauf reduzieren.
2
http://hackage.haskell.org/package/criterion
48
7.2 Ergebnisse
GHC (in der verwendeten Version 7.4.2) besitzt eine parallele Garbage Collection. Wird
ein Programm auf mehreren Rechenkernen ausgeführt, werden in der Standardeinstellung
alle Kerne zum Freigeben von nicht mehr verwendetem Speicher genutzt. Sie lässt sich deaktivieren, sodass auch bei der Ausführung eines Programms auf mehreren Rechenkernen
nur ein Kern für die Garbage Collection genutzt wird. Zwei kurze Testläufe zum Vergleich
der parallelen Garbage Collection mit der sequentiellen zeigten, dass die parallele für alle
getesteten parallelen Implementierungen des Davis-Putnam-Algorithmus von Vorteil ist.
Nur auf die sequentielle Variante des Algorithmus wirkt sie sich negativ aus.
Deshalb wurde als Vergleichswert für die Laufzeitmessungen für jede Formel die sequentielle Implementierung auf einem Kern ausgeführt, also ohne parallele Garbage Collection.
Alle Laufzeiten im Folgenden sind, soweit nicht anders erwähnt, relative Zahlen in Bezug auf die sequentielle Variante, deren Laufzeit für alle Formeln auf 1,0 festgelegt wird
(und deswegen nicht in den Tabellen mit aufgeführt wird). Je kleiner die dargestellten
Laufzeiten, desto schneller ist die zugehörige Variante also im Vergleich. In den Tabellen
dargestellt sind für die Formelgruppen immer Durchschnitt und Median aller Messungen.
Die absoluten Laufzeiten der sequentiellen Variante liegen bei den unerfüllbaren Formeln
der Gruppen uuf125-538 und uuf150-645 zwischen etwa 1 und 20 Sekunden, für uuf200860 bei bis zu knapp 5 Minuten. Die erfüllbaren Formeln sind natürlich zum Teil deutlich
schneller gelöst.
Unter der in Kapitel 6.2 genannten Internetadresse finden sich neben dem Quellcode
auch die Rohdaten der Messungen, sowohl die relativen Werte als auch die absoluten
Zeiten. Auf den folgenden Seiten werden nur die interessantesten Ergebnisse dargestellt.
7.2.1 Impliziter und expliziter Future
Zwischen den Concurrent-Haskell-Varianten mit implizitem und explizitem Future, Con
und Con2, zeigt sich kein signifikanter Unterschied in der Laufzeit. Tabelle 7.1 zeigt die
Durchschnittswerte (oberer Wert für jede Formelgruppe) und den Median (unterer Wert)
für die Ausführung auf zwei und vier Rechenkernen mit verschiedenen Parallelisierungstiefen. Ein messbarer Unterschied war eigentlich auch nicht zu erwarten, denn die Abfrage
der MVar in Con2 findet zu dem Zeitpunkt statt, an dem der Wert auch gebraucht wird.
Im Folgenden betrachten wir deswegen die Variante Con2 nicht weiter.
7.2.2 Parallelisierungstiefe bei der Eval-Variante
Tabelle 7.2 zeigt die Ausführung von Evalt mit verschiedenen Parallelisierungstiefen im
Vergleich mit Eval, also ohne beschränkte Parallelisierungstiefe, auf zwei, vier und acht
Rechenkernen.
Generell ist zu sehen, dass eine höhere Parallelisierungstiefe auch zu einer besseren
Laufzeit führt. Der Vorteil durch die feinere Partitionierung des Suchraums scheint fast
immer den Overhead durch die Verwaltung einer höheren Sparkanzahl zu überwiegen. In
einzelnen Fällen sind die Ergebnisse für geringere Parallelisierungstiefen etwas besser, das
49
Tabelle 7.1: Con und Con2 mit verschiedenen Paralellisierungstiefen im Vergleich (Durchschnitt und Median, jeweils relativ zur sequentiellen Variante Seq)
2 Kerne
Variante
Tiefe
Con
Con2
Con
1
Con2
Con
4
Con2
100
uf125-538
1,138
1,163
1,141
1,165
3,157
1,758
3,063
1,756
6,389
2,250
6,139
2,211
uf150-645
1,080
1,080
1,074
1,085
5,058
1,285
4,979
1,285
18,523
2,496
19,947
2,459
uuf125-538
0,735
0,706
0,738
0,709
0,708
0,687
0,703
0,693
0,643
0,642
0,633
0,635
uuf150-645
0,810
0,734
0,775
0,786
0,673
0,672
0,680
0,684
0,643
0,644
0,634
0,635
Con2
Con
Con2
4 Kerne
Variante
Tiefe
50
Con
Con2
Con
2
8
100
uf125-538
1,059
1,071
1,090
1,152
2,541
1,099
2,544
1,089
11,454
1,385
10,510
1,360
uf150-645
1,024
0,804
1,030
0,888
5,611
0,912
5,585
0,894
11,454
1,385
10,510
1,360
uuf125-538
0,581
0,573
0,585
0,569
0,345
0,342
0,344
0,340
0,360
0,360
0,354
0,353
uuf150-645
0,591
0,604
0,568
0,551
0,328
0,330
0,329
0,328
0,357
0,356
0,352
0,351
Tabelle 7.2: Evalt mit verschiedenen Paralellisierungstiefen und Eval (Durchschnitt und
Median, jeweils relativ zu Seq)
2 Kerne
Tiefe
1
2
4
Eval
uf125-538
1,017
1,076
0,978
1,070
0,975
1,067
1,006
1,075
uf150-645
1,000
1,066
1,078
1,027
0,969
0,998
0,939
0,980
uf200-860
1,175
1,079
1,152
1,082
1,348
1,085
1,028
1,085
uuf125-538
0,680
0,651
0,646
0,611
0,580
0,569
0,540
0,539
uuf150-645
0,715
0,687
0,638
0,637
0,564
0,553
0,543
0,543
uuf200-860
0,655
0,651
0,627
0,610
0,576
0,570
0,543
0,544
4 Kerne
8 Kerne
Tiefe
2
4
8
Eval
Tiefe
3
6
12
Eval
uf125-538
0,921
1,016
0,877
0,883
0,867
0,898
0,868
0,890
uf125-538
0,914
0,885
0,889
0,649
0,825
0,579
0,821
0,611
uf150-645
0,886
0,800
0,806
0,689
0,784
0,695
0,781
0,626
uf150-645
0,949
0,682
0,820
0,520
0,739
0,477
0,766
0,466
uf200-860
1,003
0,817
0,980
0,782
0,971
0,715
0,976
0,740
uf200-860
1,075
0,672
0,925
0,483
0,920
0,491
0,932
0,460
uuf125-538
0,508
0,493
0,395
0,374
0,314
0,310
0,296
0,297
uuf125-538
0,407
0,417
0,267
0,255
0,196
0,190
0,186
0,184
uuf150-645
0,521
0,541
0,368
0,359
0,302
0,300
0,292
0,292
uuf150-645
0,372
0,368
0,225
0,225
0,179
0,177
0,177
0,176
uuf200-860
0,485
0,509
0,368
0,369
0,296
0,295
0,288
0,288
uuf200-860
0,351
0,349
0,216
0,218
0,171
0,170
0,169
0,169
51
Tabelle 7.3: Effizienz von Eval für unerfüllbare Formeln (basierend auf der durchschnittlichen Laufzeit)
Kerne
2
4
8
uuf125-538
0,926
0,845
0,672
uuf150-645
0,921
0,856
0,706
uuf200-860
0,921
0,868
0,740
kann aber auch auf die Messungenauigkeit zurückzuführen sein. Vor allem verbessert auch
eine minimale Tiefe in den Fällen, in den die Evalt bzw. Eval deutlich langsamer ist als
die sequentielle Variante, nicht die schlechten Laufzeiten. Durch solche einzelnen Ausreißer
weicht der Mittelwert bei den erfüllbaren Formeln teilweise deutlich vom Median ab.
Während der Durchschnitt für die erfüllbaren Formeln jeweils nicht besonders gut aussieht, zeigen Abbildung 7.1 und 7.2, dass in einzelnen Fällen sehr wohl ein deutlicher
Geschwindigkeitsvorteil vorhanden ist. Gerade mit acht Rechenkernen sind die Schwankungen der Laufzeiten allerdings sehr stark. In einzelnen Fällen scheint die Koordination
deutlich Zeit zu kosten. Natürlich kann die Lösungssuche für einzelne Formeln gar nicht
von der Parallelisierung profitieren, wenn der sequentielle Algorithmus sofort den richtigen
Weg einschlägt.
Für die unerfüllbaren Formeln ist in Tabelle 7.3 die Effizienz von Eval für zwei, vier
und acht Kerne für die verschiedenen Formelgruppen dargestellt. Die Effizienz gibt an, wie
gut die Rechenkerne im Vergleich zur sequentiellen Variante ausgelastet werden können
(Quotient aus dem Kehrwert der relativen Laufzeit und der Zahl der Kerne). Eine Effizienz
von 1 wäre daher optimal für unerfüllbare Formeln. Ob die Effizienz für größere Formeln
weiter zunimmt, wie man aufgrund der Werte in der Tabelle vermuten könnte, müsste in
weiteren Tests untersucht werden.
7.2.3 Vergleich von Eval und Con
Da die Varianten Eval und Con beide auf Ansatz I basieren, sollten sie gut vergleichbare
Ergebnisse liefern. Tatsächlich zeigt sich in Tabelle 7.4, dass Eval im Durchschnitt in allen
Fällen besser ist, egal wie hoch die Parallelisierungstiefe für Con. Zudem reagiert Con sehr
sensibel auf eine zu große Tiefe. In Abbildung 7.3 und 7.4 ist zu sehen, dass Eval nicht
nur im Durchschnitt, sondern auch für fast jede einzelne Formel etwas schneller ist, und
im Fall, dass sie länger als die sequentielle Variante braucht, deutlich besser als Con ist,
die dann extrem hohe Laufzeiten aufweist.
52
Abbildung 7.1: Laufzeiten für Eval für die einzelnen Formeln aus der Gruppe uf150-645
(jeweils relativ zu Seq)
2,0
1,8
1,6
1,4
1,2
1,0
0,8
0,6
0,4
0,2
0,0
2 Kerne
4 Kerne
8 Kerne
Abbildung 7.2: Laufzeiten für Eval für die einzelnen Formeln aus der Gruppe uf200-860
(jeweils relativ zu Seq)
2,0
1,8
1,6
1,4
1,2
1,0
0,8
0,6
0,4
0,2
0,0
2 Kerne
4 Kerne
8 Kerne
53
Tabelle 7.4: Eval und Con mit verschiedenen Paralellisierungstiefen (Durchschnitt und
Median, jeweils relativ zu Seq)
2 Kerne
Variante
Tiefe
Con
Eval
1
2
4
8
uf125-538
1,189
1,191
1,954
1,691
3,113
1,708
4,591
1,957
1,006
1,075
uf150-645
1,103
1,100
1,888
1,151
4,885
1,294
10,354
1,641
0,939
0,980
uuf125-538
0,753
0,746
0,793
0,772
0,706
0,702
0,603
0,600
0,540
0,539
uuf150-645
0,868
0,789
0,816
0,778
0,669
0,672
0,595
0,593
0,543
0,543
4 Kerne
Variante
Tiefe
Con
Eval
2
4
8
16
uf125-538
1,141
1,012
1,727
1,137
2,480
1,106
3,298
1,190
0,868
0,890
uf150-645
1,030
0,914
2,656
0,840
5,903
0,888
9,154
1,217
0,781
0,626
uuf125-538
0,583
0,574
0,460
0,445
0,344
0,342
0,362
0,362
0,296
0,297
uuf150-645
0,695
0,697
0,408
0,404
0,331
0,330
0,355
0,355
0,292
0,292
8 Kerne
Variante
Tiefe
54
Con
Eval
3
6
12
24
uf125-538
1,283
1,042
1,485
0,749
1,856
0,707
2,024
0,737
0,821
0,611
uf150-645
1,265
0,775
2,770
0,659
4,774
0,649
5,648
0,777
0,766
0,466
uuf125-538
0,484
0,470
0,308
0,296
0,219
0,218
0,217
0,217
0,186
0,184
uuf150-645
0,427
0,424
0,255
0,245
0,202
0,202
0,209
0,209
0,177
0,176
Abbildung 7.3: Laufzeiten für Con 6 und Eval für die einzelnen Formeln aus der Gruppe
uf125-538, 8 Kerne (jeweils relativ zu Seq)
2,0
1,8
1,6
1,4
1,2
1,0
0,8
0,6
0,4
0,2
0,0
Con 6
Eval
Abbildung 7.4: Laufzeiten für Con 6 und Eval für die einzelnen Formeln aus der Gruppe
uf150-645, 8 Kerne (jeweils relativ zu Seq)
2,0
1,8
1,6
1,4
1,2
1,0
0,8
0,6
0,4
0,2
0,0
Con 6
Eval
55
Tabelle 7.5: Maximale Verzweigungstiefe der Suchbäume für die ersten 10 Formeln der
Gruppen uf200-860 und uuf200-860
Gruppe
uf200-860
uuf200-860
001.cnf
002.cnf
003.cnf
004.cnf
005.cnf
36
35
53
46
38
29
35
29
33
29
006.cnf
007.cnf
008.cnf
009.cnf
010.cnf
37
31
38
36
35
33
33
31
33
32
7.2.4 Parallelisierungstiefe bei Con’
Die versehentlich entstandene Variante Con’ zeigt deutlich bessere Ergebnisse als die
geplante Variante Con, allerdings erst, wenn die Parallelisierungstiefe hoch genug ist. Bei
nur geringer Tiefe scheint fast gar keine Parallelisierung stattzufinden. Für unerfüllbare
Formeln ist Con’ nur auf acht Kernen etwas schlechter als Con, was darauf hindeutet, das
irgendwie später parallelisiert wird. Der Ausdruck
pp <- positivePath
scheint den positiven Pfad nur teilweise auszuwerten, bevor die Berechnung des negativen
abgespalten wird (siehe Abschnitt 6.2.2).
Bezüglich der Parallelisierungstiefe scheint für Con’, wie es auch bei Evalt der Fall ist,
zu gelten, dass eine unbeschränkte am besten ist. Das ist in Tabelle 7.6 zu sehen. Die Tiefe
100 ist für alle Testformeln ausreichend, um unbeschränkt zu parallelisieren, da für alle
die maximale Verzweigungstiefe darunter liegt (siehe Tabelle 7.5; größere Formeln haben
natürlich eine tendenziell höhere Verzweigungstiefe).
Vergleicht man Abbildung 7.5 und 7.6 mit den entsprechenden Abbildungen 7.1 und
7.2 für Eval, ist zu sehen, dass Con’ mit unbeschränkter Parallelisierungstiefe nicht nur
eine höhere Zahl kurzer Laufzeiten hat, sondern auch, dass sie eigentlich immer von mehr
Rechenkernen profitiert, wohingegen Eval auf acht Rechenkernen ja einige starke negative
Ausreißer hat.
56
Tabelle 7.6: Con’ mit verschiedenen Paralellisierungstiefen (Durchschnitt und Median,
jeweils relativ zu Seq)
2 Kerne
Tiefe
1
2
4
100
uf125-538
1,088
1,061
1,030
1,067
0,945
0,930
0,989
0,895
uf150-645
1,095
1,086
1,078
1,082
0,889
0,835
0,906
0,656
uf200-860
nicht gemessen
nicht gemessen
0,918
0,770
uuf125-538
1,251
1,249
1,011
1,031
0,736
0,727
0,597
0,597
uuf150-645
1,286
1,305
0,966
0,890
0,747
0,710
0,595
0,594
uuf200-860
nicht gemessen
nicht gemessen
4 Kerne
0,606
0,606
8 Kerne
Tiefe
2
4
8
100
Tiefe
3
6
12
100
uf125-538
1,094
1,086
0,954
1,025
0,693
0,602
0,623
0,561
uf125-538
1,030
1,082
0,859
0,772
0,607
0,496
0,565
0,447
uf150-645
1,120
1,108
0,912
0,943
0,685
0,569
0,546
0,424
uf150-645
1,035
1,088
0,821
0,820
0,552
0,458
0,487
0,382
0,505
0,430
uf200-860
uf200-860
nicht gemessen
nicht gemessen
nicht gemessen
nicht gemessen
0,330
0,271
uuf125-538
1,092
1,125
0,798
0,762
0,502
0,469
0,382
0,379
uuf125-538
0,978
0,966
0,677
0,647
0,409
0,392
0,370
0,354
uuf150-645
1,054
0,973
0,786
0,784
0,431
0,432
0,351
0,344
uuf150-645
0,995
0,972
0,602
0,593
0,327
0,318
0,284
0,274
0,332
0,331
uuf200-860
uuf200-860
nicht gemessen
nicht gemessen
nicht gemessen
nicht gemessen
0,208
0,206
57
Abbildung 7.5: Laufzeiten für Con’ 100 für die einzelnen Formeln aus der Gruppe uf150645 (jeweils relativ zu Seq)
2,0
1,8
1,6
1,4
1,2
1,0
0,8
0,6
0,4
0,2
0,0
2 Kerne
4 Kerne
8 Kerne
Abbildung 7.6: Laufzeiten für Con’ 100 für die einzelnen Formeln aus der Gruppe uf200860 (jeweils relativ zu Seq)
2,0
1,8
1,6
1,4
1,2
1,0
0,8
0,6
0,4
0,2
0,0
58
2 Kerne
4 Kerne
8 Kerne
Abbildung 7.7: Laufzeiten für Amb 1, Amb 2 bzw. Amb 3 für die einzelnen Formeln aus der
Gruppe uf150-645 (jeweils relativ zu Seq)
2,0
1,8
1,6
1,4
1,2
1,0
0,8
0,6
0,4
0,2
0,0
2 Kerne
4 Kerne
8 Kerne
7.2.5 Parallelisierungstiefe bei Amb
Amb ist aufgrund des theoretischen Vorteils von Ansatz II die vielversprechendste Variante.
Die Ergebnisse sind allerdings alles andere als eindeutig. Diese Variante ist sehr fragil
bezüglich der Paralellisierungstiefe bei den erfüllbaren Formeln. Eine minimale Tiefe, die
so viele Prozesse wie Rechenkerne erzeugt, ist in den meisten Fällen am besten. Das ist
in Tabelle 7.7 zu sehen.
Bei einzelnen Formeln hilft eine leicht höhere Parallelisierungstiefe; dafür steigt damit
dann die Laufzeit in vielen anderen Fällen deutlich (siehe in Tabelle 7.8 die markierten
Laufzeiten für vier Kerne). Für die unerfüllbare Formeln gilt andersherum, dass eine
höhere Parallelisierungstiefe besser ist.
In Abbildung 7.7 und 7.8 sind die Laufzeiten für die einzelnen Formeln aus den Gruppen uf150-645 und uf200-860 mit minimaler Parallelisierungstiefe (1, 2 und 3 für zwei,
vier bzw. acht Rechenkerne) dargestellt. Interessanterweise ergeben sich bei der Gruppe
uf150-645 extrem kurze Laufzeiten für zwei Kerne (nur etwa 5–10 % der sequentiellen
Laufzeit), was in Tabelle 7.8 zu sehen ist. Mit höherer Parallelisierungstiefe oder auf
mehr Rechenkerne sind die Ergebnisse gleich deutlich schlechter. Allerdings konnte die
deutliche Verschlechterung bei höherer Parallelisierungstiefe für die Formeln 006.cnf und
007.cnf in gesonderten Einzelmessungen nicht reproduziert werden (für die Tiefe 2 war
die Laufzeit nicht etwa zehnmal, sondern nur doppelt so hoch wie für die Tiefe 1). Bei
vier und acht Kernen wurden solche extrem niedrigen Laufzeiten nicht gemessen.
59
Tabelle 7.7: Amb mit verschiedenen Paralellisierungstiefen (Durchschnitt und Median, jeweils relativ zu Seq)
2 Kerne
Tiefe
1
2
4
8
uf125-538
0,748
0,950
1,486
1,368
2,681
1,422
4,466
1,770
uf150-645
0,507
0,406
2,495
0,784
4,798
1,070
10,325
1,590
uf200-860
0,772
0,848
2,135
1,153
5,843
1,212
14,953
1,398
uuf125-538
0,751
0,693
0,684
0,638
0,600
0,590
0,575
0,571
uuf150-645
0,840
0,790
0,712
0,717
0,595
0,581
0,566
0,566
uuf200-860
0,767
0,747
0,758
0,805
0,634
0,632
0,607
0,601
4 Kerne
8 Kerne
Tiefe
2
4
8
16
Tiefe
3
6
12
24
uf125-538
1,049
0,858
1,560
0,953
2,433
0,973
3,225
1,205
uf125-538
1,029
0,590
1,381
0,624
1,783
0,685
1,995
0,738
uf150-645
1,309
0,433
2,560
0,581
5,573
0,859
8,869
1,187
uf150-645
1,281
0,349
2,629
0,509
4,620
0,643
5,765
0,775
uf200-860
1,581
0,729
4,189
0,858
8,697
0,880
16,748
1,021
uf200-860
1,906
0,591
4,853
0,455
7,448
0,596
10,825
0,669
uuf125-538
0,591
0,555
0,410
0,387
0,334
0,330
0,350
0,350
uuf125-538
0,452
0,448
0,276
0,261
0,217
0,211
0,212
0,212
uuf150-645
0,628
0,622
0,382
0,358
0,319
0,317
0,345
0,346
uuf150-645
0,423
0,413
0,238
0,239
0,203
0,202
0,210
0,210
uuf200-860
0,638
0,655
0,423
0,417
0,354
0,342
0,347
0,347
uuf200-860
0,463
0,463
0,251
0,250
0,202
0,202
0,222
0,222
60
Tabelle 7.8: Laufzeiten der einzelnen Formeln mit Amb für die Formelgruppe uf150-645
(jeweils relativ zu Seq)
2 Kerne
4 Kerne
Tiefe
1
2
4
8
Tiefe
2
4
8
16
001.cnf
002.cnf
003.cnf
004.cnf
005.cnf
0,219
0,052
0,633
0,130
1,096
0,680
0,835
0,536
0,372
7,921
1,223
3,554
0,617
0,755
11,451
2,281
12,590
0,682
0,989
12,054
001.cnf
002.cnf
003.cnf
004.cnf
005.cnf
0,361
0,452
0,415
0,221
3,223
0,661
2,098
0,371
0,407
6,231
1,183
6,766
0,378
0,529
6,523
1,635
9,153
0,412
0,606
7,803
006.cnf
007.cnf
008.cnf
009.cnf
010.cnf
0,074
0,048
1,160
0,316
1,036
0,567
0,561
15,743
0,391
3,501
0,990
0,903
42,758
0,640
3,120
1,540
0,962
94,395
0,680
4,108
006.cnf
007.cnf
008.cnf
009.cnf
010.cnf
0,306
0,305
8,781
0,216
1,870
0,554
0,450
22,757
0,343
1,674
0,837
0,515
52,052
0,370
2,268
1,162
0,588
91,873
0,434
2,749
011.cnf
012.cnf
013.cnf
014.cnf
015.cnf
1,109
0,048
0,631
0,704
1,082
4,209
0,505
0,847
0,953
2,547
8,576
0,848
1,052
0,542
3,026
11,754
0,949
1,510
0,927
4,768
011.cnf
012.cnf
013.cnf
014.cnf
015.cnf
2,288
0,285
0,466
0,801
1,277
4,341
0,465
0,566
0,295
1,576
6,308
0,511
0,829
0,527
2,563
8,561
0,589
1,121
0,611
3,266
016.cnf
017.cnf
018.cnf
019.cnf
020.cnf
0,056
1,148
0,497
0,023
0,087
0,531
6,573
1,537
0,358
0,732
0,972
11,310
1,742
1,088
0,785
1,640
50,044
2,106
1,213
1,316
016.cnf
017.cnf
018.cnf
019.cnf
020.cnf
0,293
3,238
0,799
0,225
0,362
0,531
5,930
0,932
0,597
0,433
0,880
25,946
1,092
0,665
0,716
1,212
42,415
1,513
0,795
0,881
61
Abbildung 7.8: Laufzeiten für Amb 1, Amb 2 bzw. Amb 3 für die einzelnen Formeln aus der
Gruppe uf200-860 (jeweils relativ zu Seq)
2,0
1,8
1,6
1,4
1,2
1,0
0,8
0,6
0,4
0,2
0,0
2 Kerne
4 Kerne
8 Kerne
7.2.6 Vergleich von Eval, Con’ und Amb
Wie sich aus den vorigen Abschnitten ergibt, sind die interessantesten Varianten Eval,
Con’ mit unbeschränkter Parallelisierungstiefe und Amb mit minimaler Parallelisierungstiefe (sodass ein Prozess pro Rechenkern erstellt wird). Tabelle 7.9 zeigt alle diese Varianten im Vergleich, außerdem Amb mit höherer Tiefe zum Vergleich bezüglich der unerfüllbaren Formeln.
Für unerfüllbare Formeln ist Eval klar am besten. Die Einzelergebnisse für die erfüllbaren Formeln sind in Abbildung 7.9 bis 7.11 dargestellt. Im Mittel ist hier Con’ am besten
und bis auf der Ausführung auf zwei Kernen in den meisten Fällen etwas besser als Eval.
Amb ist auf zwei Kernen am schnellsten, insbesondere für einige Formeln aus uf150-645.
Bei vier und acht Rechenkernen hingegen sind einzelne Ergebnisse nur in wenigen Fällen
etwas besser, dafür gibt es extreme Ausreißer nach oben.
62
Tabelle 7.9: Eval, Con’ 100 und Amb mit verschiedenen Paralellisierungstiefen (Durchschnitt und Median, jeweils relativ zu Seq)
2 Kerne
Variante
Tiefe
Eval
Con’
Amb
1
8
uf125-538
1,006
1,075
0,989
0,895
0,748
0,950
4,466
1,770
uf150-645
0,939
0,980
0,906
0,656
0,507
0,406
10,325
1,590
uf200-860
1,028
1,085
0,918
0,770
0,772
0,848
14,953
1,398
uuf125-538
0,540
0,539
0,597
0,597
0,751
0,693
0,575
0,571
uuf150-645
0,543
0,543
0,595
0,594
0,840
0,790
0,566
0,566
uuf200-860
0,543
0,544
0,606
0,606
0,767
0,747
0,607
0,601
4 Kerne
Variante
Tiefe
Eval
8 Kerne
Con’
Amb
2
16
Variante
Tiefe
Eval
Con’
Amb
3
24
uf125-538
0,868
0,890
0,623
0,561
1,049
0,858
3,225
1,205
uf125-538
0,821
0,611
0,565
0,447
1,029
0,590
1,995
0,738
uf150-645
0,781
0,626
0,546
0,424
1,309
0,433
8,869
1,187
uf150-645
0,766
0,466
0,487
0,382
1,281
0,349
5,765
0,775
uf200-860
0,976
0,740
0,505
0,430
1,581
0,729
16,748
1,021
uf200-860
0,932
0,460
0,330
0,271
1,906
0,591
10,825
0,669
uuf125-538
0,296
0,297
0,382
0,379
0,591
0,555
0,350
0,350
uuf125-538
0,186
0,184
0,370
0,354
0,452
0,448
0,212
0,212
uuf150-645
0,292
0,292
0,351
0,344
0,628
0,622
0,345
0,346
uuf150-645
0,177
0,176
0,284
0,274
0,423
0,413
0,210
0,210
uuf200-860
0,288
0,288
0,332
0,331
0,638
0,655
0,347
0,347
uuf200-860
0,169
0,169
0,208
0,206
0,463
0,463
0,222
0,222
63
Abbildung 7.9: Laufzeiten für Eval, Con’ und Amb für die einzelnen Formeln aus der
Gruppe uf125-538 auf 2, 4 bzw. 8 Kernen (jeweils relativ zu Seq)
2,0
1,8
1,6
1,4
1,2
1,0
0,8
0,6
0,4
0,2
0,0
Eval
Con'
Amb 1
Eval
Con'
Amb 2
Eval
Con'
Amb 3
2,0
1,8
1,6
1,4
1,2
1,0
0,8
0,6
0,4
0,2
0,0
2,0
1,8
1,6
1,4
1,2
1,0
0,8
0,6
0,4
0,2
0,0
64
Abbildung 7.10: Laufzeiten für Eval, Con’ und Amb für die einzelnen Formeln aus der
Gruppe uf150-645 auf 2, 4 bzw. 8 Kernen (jeweils relativ zu Seq)
2,0
1,8
1,6
1,4
1,2
1,0
0,8
0,6
0,4
0,2
0,0
Eval
Con'
Amb 1
Eval
Con'
Amb 2
Eval
Con'
Amb 3
2,0
1,8
1,6
1,4
1,2
1,0
0,8
0,6
0,4
0,2
0,0
2,0
1,8
1,6
1,4
1,2
1,0
0,8
0,6
0,4
0,2
0,0
65
Abbildung 7.11: Laufzeiten für Eval, Con’ und Amb für die einzelnen Formeln aus der
Gruppe uf200-860 auf 2, 4 bzw. 8 Kernen (jeweils relativ zu Seq)
2,0
1,8
1,6
1,4
1,2
1,0
0,8
0,6
0,4
0,2
0,0
Eval
Con'
Amb 1
Eval
Con'
Amb 2
Eval
Con'
Amb 3
2,0
1,8
1,6
1,4
1,2
1,0
0,8
0,6
0,4
0,2
0,0
2,0
1,8
1,6
1,4
1,2
1,0
0,8
0,6
0,4
0,2
0,0
66
8 Fazit
Es gibt also keinen klaren Sieger. Allerdings sollte geprüft werden, ob die Ergebnisse repräsentativ sind. Aus Zeitgründen wurde nur eine sehr begrenzte Menge von Formeln
getestet. Und da vor allem die Varianten, die Concurrent Haskell verwenden, sehr hohe
Schwankungen in der Laufzeit haben, könnten die wenigen Messiterationen zu einigen
irreführenden Ergebnisse geführt haben. Auch steht die Frage im Raum, wie sich das verwendete Testsystem auf die Laufzeit der Varianten auswirkt und was für ein Verhalten
sich mit noch mehr Rechenkernen oder Prozessoren zeigt. Eine problematische Eigenschaft
dürfte diesbezüglich für alle Varianten sein, dass die Implementierungen in Haskell einen
hohen Speicherverbrauch haben. Was dort genauer passiert, müsste untersucht werden.
Dann könnte man vielleicht auch erkennen, wie mit Eval die Effizienz für unerfüllbare
Formeln noch gesteigert werden könnte. Die Ergebnisse aus [BS96] zeigen ja, dass eine Effizienz von nahezu 1 möglich ist, dass also acht Rechenkerne auch fast eine Verachtfachung
der Geschwindigkeit zur Folge haben könnten.
Auch ist klar zu sehen, dass die Variante Amb ihr Potential nicht ausschöpfen kann. Ihr
Problem ist vor allem, dass die richtige Parallelisierungstiefe sehr von der Testformel abhängt. Zudem kann es bei einem ungleichmäßigen Suchbaum sein, dass sie für einen Teil
zu hoch, für den anderen zu niedrig ist; dass sie also gar nicht immer optimal eingestellt
werden kann. Hier wäre zu untersuchen, ob sich eine Abschätzung des Arbeitsaufwandes ähnlich wie in [BS96] effizient implementieren ließe und ob sich damit eine bessere
Parallelisierung realisieren ließe.
Ein weitere Forschungsrichtung wäre der Versuch, Ansatz II mit der Eval-Monade zu
implementieren. Dazu müssten wahrscheinlich die Interna von GHC modifiziert werden,
um zwei Sparks parallel auswerten und dann den schneller ausgewerteten zuerst abfragen
zu können.
Außerdem stellt sich die Frage, warum und wie die Variante Con’ genau funktioniert.
Dazu müsste man sich eingehender mit der Funktionsweise von Haskell beschäftigen. Für
die praktische Verwendung bieten zumindest aber Con’ – wobei man sie so modifizieren
sollte, dass die Parallelisierungstiefe nicht beschränkt wird – und Eval einen ziemlich
sicheren Geschwindigkeitsvorteil, wenn mehrere Rechenkerne zur Verfügung stehen. Eval
ist dabei interessanter für vorwiegend unerfüllbare, Con’ eher für vorwiegend erfüllbare
Formeln.
Tritt man einen Schritt zurück, kann man sehen, dass es Haskell tatsächlich ermöglicht,
mit sehr geringem Implementierungsaufwand eine brauchbare Beschleunigung durch Parallelisierung zu erreichen. Von daher dürfte sich eine intensivere Beschäftigung mit den
Parallelisierungsansätzen in Haskell lohnen, um zu sehen, was dort noch an Optimierungspotential vorhanden ist. Davon könnte nicht nur die Parallelisierung des Davis-PutnamAlgorithmus profitieren, sondern auch andere Problemstellungen.
67
Literaturverzeichnis
[BS96]
Böhm, M. ; Speckenmeyer, E.: A fast parallel SAT-solver—Efficient
workload balancing. In: Annals of Mathematics and Artificial Intelligence
17 (1996), Nr. 2, S. 381–400
[Coo71]
Cook, S. A.: The Complexity of Theorem-Proving Procedures. In: Proceedings of the third annual ACM symposium on Theory of computing, 1971, S.
151–158
[DLL62]
Davis, M. ; Logemann, G. ; Loveland, D.: A Machine Program for
Theorem-Proving. In: Communications of the ACM 5 (1962), Nr. 7, S.
394–397
[DP60]
Davis, M. ; Putnam, H.: A Computing Procedure for Quantification Theory.
In: Journal of the ACM (JACM) 7 (1960), Nr. 3, S. 201–215
[EMC+ 01] Ehrig, H. ; Mahr, B. ; Cornelius, F. ; Große-Rhode, M. ; Zeitz,
P.: Mathematisch-strukturelle Grundlagen der Informatik. Berlin : Springer,
2001. – ISBN 3–540–41923–3
[Hay97]
Hayes, B.: Computing Science: Can’t Get No Satisfaction. In: American
Scientist (1997), S. 108–112
[HHJW07] Hudak, P. ; Hughes, J. ; Jones, S. P. ; Wadler, P.: A History of Haskell Being Lazy With Class. In: Proceedings of the third ACM SIGPLAN
conference on History of programming languages, 2007, S. 12–1
[HS00]
Hoos, H. ; Stiitzle, T.: SATLIB: An Online Resource for Research on SAT.
In: Sat (2000), S. 283
[KS04]
Kautza, H. ; Selmanb, B.: The State of SAT. (2004)
[Lip11]
Lipovača, Miran: Learn You a Haskell for Great Good! No Starch Press,
April 2011. – ISBN 978–1–59327–283–8
[Mar12]
Marlow, S.: Parallel and Concurrent Programming in Haskell. 2012. –
http://community.haskell.org/~simonmar/par-tutorial.pdf, abgerufen am 15.
Oktober 2012
[MML+ 10] Marlow, S. ; Maier, P. ; Loidl, H. W. ; Aswad, M. K. ; Trinder, P.: Seq
no more: Better Strategies for Parallel Haskell. In: Proceedings of the third
ACM Haskell symposium on Haskell, 2010, S. 91–102
68
[MNJ11]
Marlow, S. ; Newton, R. ; Jones, S. P.: A Monad for Deterministic
Parallelism. In: Proceedings of the 4th ACM symposium on Haskell, 2011, S.
71–82
[MPJS09] Marlow, S. ; Peyton Jones, S. ; Singh, S.: Runtime support for multicore
Haskell. In: ACM Sigplan Notices Bd. 44, 2009, S. 65–78
[Pet11]
Petricek, Tomas: Explicit speculative parallelism for Haskell’s Par monad.
http://tomasp.net/blog/speculative-par-monad.aspx. Mai 2011. – abgerufen
am 15. Oktober 2012
[PJ89]
Peyton Jones, S.: Parallel Implementations of Functional Programming
Languages. In: The Computer Journal 32 (1989), April, Nr. 2, S. 175–186
[PJW93]
Peyton Jones, S. L. ; Wadler, P.: Imperative Functional Programming. In:
Proceedings of the 20th ACM SIGPLAN-SIGACT symposium on Principles
of programming languages, 1993, S. 71–84
[PRV10]
Prabhu, P. ; Ramalingam, G. ; Vaswani, K.: Safe Programmable Speculative Parallelism. In: ACM Sigplan Notices Bd. 45, 2010, S. 50–61
[Sab12]
Sabel, D.:
Nebenläufige Programmierung: Praxis und Semantik
(Vorlesungsskript).
Februar 2012. –
http://www.ki.informatik.unifrankfurt.de/lehre/WS2011/TIDS/skript/skript-07-Feb-2012.pdf, abgerufen
am 15. Oktober 2012
[SS11]
Schmidt-Schauß, M.: Einführung in die Methoden der Künstlichen Intelligenz (Vorlesungsskript). April 2011. – http://www.ki.informatik.unifrankfurt.de/lehre/SS2011/KI/, abgerufen am 15. Oktober 2012
[SSS11a]
Sabel, David ; Schmidt-Schauß, Manfred: A Contextual Semantics for
Concurrent Haskell with Futures. In: Proceedings of the 13th international
ACM SIGPLAN symposium on Principles and practices of declarative programming, 2011, S. 101–112
[SSS11b]
Schmidt-Schauß, M. ; Sabel, D.: Einführung in die Funktionale Programmierung (Vorlesungsskript). Dezember 2011. – http://www.ki.informatik.unifrankfurt.de/lehre/WS2011/EFP/skript/skript.pdf, abgerufen am 15. Oktober 2012
[ZB96]
Zhang, H. ; Bonacina, M.: PSATO: A Distributed Propositional Prover and
Its Application to Quasigroup Problems. In: Journal of Symbolic Computation
21 (1996), Nr. 4–6, S. 543–560
69
Herunterladen