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