Optimierung von Haskellprogrammen - Teil 1
Geht es um funktionale Programmierung denkt der eine sofort an Kombinatoren wie map, fold und zipWith, der andere an Rekursion und Seiteneffekte, und wieder ein anderer an Monaden und Typsysteme. Selten, wenn überhaupt, bringt man funktionale Programmierung mit dem Höchstleistungsrechnen in Verbindung. In diesem Blogeintrag werden wir an einem kleinen Beispiel zeigen, worauf man achten muss, wenn man effiziente Programme in Haskell schreiben möchte.
Zahlen aufsummieren
Nehmen wir an, wir benötigen eine Funktion die alle Elemente einer List aufsummiert.
Eine Möglichkeit sum
zu implementieren ist folgende:
Das Programm arbeitet die Liste rekursiv ab und summiert die jeweiligen Elemente auf. Erste
Tests mit kleinen Listen bestätigen die Korrektheit unserer Implementierung. Bei langen Listen fällt
uns jedoch der hohe Speicherverbrauch auf und bei sehr langen terminiert unser Programm
mit der Meldung Stack space overflow: current size 33632 bytes.
Use '+RTS -Ksize -RTS' to increase it.
Erhöhen wir den vorgeschlagenen Wert gibt unser Programm
wieder die korrekte Summe aus. Jedoch terminiert es bei noch größeren Listen wieder mit einem
„stack overflow“. Das Problem ist dabei, dass die Rekursionstiefe für den vorhandenen Stack zu hoch ist. Veranschaulichen wir uns die
Auswertung eines Aufrufes von sum
in folgendem Beispiel:
Bevor wir also mit dem Aufsummieren anfangen können, müssen wir die Liste komplett durchlaufen haben.
Dadurch korreliert die Rekursionstiefe mit der Länge der Liste. Der geübte funktionale Programmierer
wird sofort vorschlagen die Funktion in eine endrekursive zu transformieren. Diese ist nun in
der Funktion go
realisiert.
Überraschenderweise treten jedoch auch bei dieser Implementierung Speicherprobleme auf. In einer Sprache mit strikter Evaluation wie z.B. ML würde diese Funktion aber den gewünschten Effekt, mit konstant großem Stackspeicher zu operieren, haben. Da Haskell jedoch eine nicht-strikte Sprache ist, wird die Addition des Akkumulators erst ausgeführt wenn dieser benötigt wird. Um also den gewünschten Effekt zu erzielen müssen wir die Auswertung der Addition in jedem Rekursionsschritt erzwingen.
Berechnungen erzwingen
Mit der Funktion seq :: a -> b -> b
wird die Auswertung von a
erzwungen und b
zurückgegeben.
Die angepasste Funktion sum''
läuft nun wie erwartet als endrekursive Funktion mit konstantem
Stackverbrauch. Eine syntaktisch weniger aufwendige Methode biete die GHC Erweiterung
BangPatterns
. Durch diese ist es möglich die strikt zu evaluierenden Werte mit einem !
zu
markieren wie in sum'''
zu sehen ist.
Weitere Optimierung durch Typ-Annotationen
Um die Implementierungen der Funktionen so allgemein wie möglich zu halten
sind diese durch die Typklasse Num a
auf die Typen beschränkt,
die Num
instantieren. Diese sind unter anderen Integer
und Int
,
die beiden Typen für Ganzzahlen. Dabei kann Integer
beliebig
große Ganzzahlen darstellen und Int
wird direkt auf die Wortgröße
der CPU-Architektur gemappt.
Bei einem Aufruf der Funktion sum'''
wie in folgendem Kontext ohne
Typannotationen
wird die Liste [1,2,3]
als Liste aus Integer
-Werten interpretiert. Da alle in der Liste enthaltenen Werte jedoch problemlos in ein
Maschinenwort passen, kann (und sollte man!) einen der Werte als Int
annotieren. Dadurch wird sum
zur Kompilierzeit auf den Typen Int
spezialisiert wodurch die effiziente Addition auf
Maschinenworten benutzt wird anstatt der langsameren Addition auf
beliebig großen Ganzzahlen. Die Ausführung von sum''' [0..1000000000]
benötigt 21 Sekunden wohingegen sum''' [0..1000000000 :: Int]
in schon 9 Sekunden
das korrekte Ergebnis liefert.
Woher wir das wissen und noch viel mehr bald in Teil 2!