Hat Haskell eine schwanzrekursive Optimierung?

88

Ich habe heute den Befehl "time" unter Unix entdeckt und dachte, ich würde ihn verwenden, um den Unterschied in den Laufzeiten zwischen tail-rekursiven und normalen rekursiven Funktionen in Haskell zu überprüfen.

Ich habe folgende Funktionen geschrieben:

--tail recursive
fac :: (Integral a) => a -> a
fac x = fac' x 1 where
    fac' 1 y = y
    fac' x y = fac' (x-1) (x*y) 

--normal recursive
facSlow :: (Integral a) => a -> a
facSlow 1 = 1
facSlow x = x * facSlow (x-1)

Diese sind gültig, da sie ausschließlich für dieses Projekt verwendet wurden. Daher habe ich mich nicht darum gekümmert, nach Nullen oder negativen Zahlen zu suchen.

Beim Schreiben einer Hauptmethode für jede Methode, beim Kompilieren und Ausführen mit dem Befehl "time" hatten beide ähnliche Laufzeiten mit der normalen rekursiven Funktion, die die rekursive Endfunktion herausschneidet. Dies steht im Widerspruch zu dem, was ich in Bezug auf die schwanzrekursive Optimierung in Lisp gehört hatte. Was ist der Grund dafür?

Haskell Schlingel
quelle
8
Ich glaube, dass TCO eine Optimierung ist, um einen Aufrufstapel zu sparen. Dies bedeutet nicht, dass Sie etwas CPU-Zeit sparen. Korrigieren Sie mich, wenn Sie falsch liegen.
Jerome
3
Ich habe es nicht mit lisp getestet, aber das Tutorial, das ich gelesen habe, implizierte, dass das Einrichten des Stacks mehr Prozessorkosten an sich verursachte, während die kompilierte bis iterative Schwanzrekursionslösung keine Energie (Zeit) dafür und damit verbrauchte war effizienter.
Haskell Schlingel
1
@ Jerome gut, es hängt von vielen Dingen ab, aber normalerweise kommen auch Caches ins Spiel, so dass TCO normalerweise auch ein schnelleres Programm produziert.
Kristopher Micinski
Was ist der Grund dafür? Mit einem Wort: Faulheit.
Dan Burton
Interessanterweise facist es mehr oder weniger so, wie ghc product [n,n-1..1]mit einer Hilfsfunktion berechnet prod, aber das product [1..n]wäre natürlich einfacher. Ich kann nur davon ausgehen, dass sie es in ihrem zweiten Argument nicht streng gemacht haben, weil ghc sehr zuversichtlich ist, dass dies zu einem einfachen Akkumulator kompiliert werden kann.
AndrewC

Antworten:

166

Haskell verwendet Lazy-Evaluation, um die Rekursion zu implementieren. Daher wird alles als Versprechen behandelt, bei Bedarf einen Wert bereitzustellen (dies wird als Thunk bezeichnet). Thunks werden nur so weit wie nötig reduziert, um fortzufahren, nicht mehr. Dies ähnelt der Art und Weise, wie Sie einen Ausdruck mathematisch vereinfachen. Daher ist es hilfreich, ihn so zu betrachten. Die Tatsache, dass die Auswertungsreihenfolge nicht in Ihrem Code angegeben ist, ermöglicht es dem Compiler, viele noch cleverere Optimierungen vorzunehmen, als Sie es bisher gewohnt waren. Kompilieren Sie mit, -O2wenn Sie optimieren möchten!

Mal sehen, wie wir facSlow 5als Fallstudie bewerten :

facSlow 5
5 * facSlow 4            -- Note that the `5-1` only got evaluated to 4
5 * (4 * facSlow 3)       -- because it has to be checked against 1 to see
5 * (4 * (3 * facSlow 2))  -- which definition of `facSlow` to apply.
5 * (4 * (3 * (2 * facSlow 1)))
5 * (4 * (3 * (2 * 1)))
5 * (4 * (3 * 2))
5 * (4 * 6)
5 * 24
120

So wie Sie sich Sorgen, haben wir eine Ansammlung von Zahlen , bevor Berechnungen passieren, aber im Gegensatz zu Ihnen besorgt, gibt es keine Stapel von facSlowFunktionsaufrufen rumhängen zu beenden warten - jede Reduktion angewandt wird , und geht weg, eine Abgangsstapelrahmen in seinem wake (das liegt daran, dass (*)es streng ist und so die Bewertung seines zweiten Arguments auslöst).

Haskells rekursive Funktionen werden nicht sehr rekursiv ausgewertet! Der einzige Stapel von Anrufen, der herumhängt, sind die Multiplikationen selbst. Wenn dies (*)als strikter Datenkonstruktor angesehen wird, wird dies als geschützte Rekursion bezeichnet (obwohl dies normalerweise bei nicht strengen Datenkonstruktoren als solche bezeichnet wird, bei denen die Datenkonstruktoren übrig bleiben - wenn sie durch weiteren Zugriff erzwungen werden).

Schauen wir uns nun die Schwanzrekursive an fac 5:

fac 5
fac' 5 1
fac' 4 {5*1}       -- Note that the `5-1` only got evaluated to 4
fac' 3 {4*{5*1}}    -- because it has to be checked against 1 to see
fac' 2 {3*{4*{5*1}}} -- which definition of `fac'` to apply.
fac' 1 {2*{3*{4*{5*1}}}}
{2*{3*{4*{5*1}}}}        -- the thunk "{...}" 
(2*{3*{4*{5*1}}})        -- is retraced 
(2*(3*{4*{5*1}}))        -- to create
(2*(3*(4*{5*1})))        -- the computation
(2*(3*(4*(5*1))))        -- on the stack
(2*(3*(4*5)))
(2*(3*20))
(2*60)
120

So können Sie sehen, dass die Schwanzrekursion selbst Ihnen weder Zeit noch Raum gespart hat. Insgesamt werden nicht nur mehr Schritte ausgeführt als facSlow 5, sondern es wird auch ein verschachtelter Thunk (hier als dargestellt {...}) erstellt, der zusätzlichen Platz benötigt , der die zukünftige Berechnung und die durchzuführenden verschachtelten Multiplikationen beschreibt.

Dieser Thunk wird dann entwirrt, indem er nach unten bewegt wird, wodurch die Berechnung auf dem Stapel neu erstellt wird. Hier besteht auch die Gefahr eines Stapelüberlaufs mit sehr langen Berechnungen für beide Versionen.

Wenn wir dies von Hand optimieren möchten, müssen wir es nur streng machen. Sie können den strengen Anwendungsoperator $!zum Definieren verwenden

facSlim :: (Integral a) => a -> a
facSlim x = facS' x 1 where
    facS' 1 y = y
    facS' x y = facS' (x-1) $! (x*y) 

Dies zwingt facS'dazu, in seinem zweiten Argument streng zu sein. (Es ist bereits in seinem ersten Argument streng, da dies bewertet werden muss, um zu entscheiden, welche Definition facS'angewendet werden soll.)

Manchmal kann Strenge enorm helfen, manchmal ist es ein großer Fehler, weil Faulheit effizienter ist. Hier ist es eine gute Idee:

facSlim 5
facS' 5 1
facS' 4 5 
facS' 3 20
facS' 2 60
facS' 1 120
120

Welches ist, was Sie erreichen wollten, denke ich.

Zusammenfassung

  • Wenn Sie Ihren Code optimieren möchten, müssen Sie zunächst mit kompilieren -O2
  • Die Schwanzrekursion ist nur dann gut, wenn sich kein Thunk ansammelt, und das Hinzufügen von Strenge hilft normalerweise, dies gegebenenfalls zu verhindern. Dies geschieht, wenn Sie ein Ergebnis erstellen, das später auf einmal benötigt wird.
  • Manchmal ist die Schwanzrekursion ein schlechter Plan, und eine vorsichtige Rekursion passt besser, dh wenn das Ergebnis, das Sie erstellen, Stück für Stück in Teilen benötigt wird. Sehen Sie diese Frage über foldrund foldlzum Beispiel, und testen sie gegeneinander an.

Probieren Sie diese beiden aus:

length $ foldl1 (++) $ replicate 1000 
    "The size of intermediate expressions is more important than tail recursion."
length $ foldr1 (++) $ replicate 1000 
    "The number of reductions performed is more important than tail recursion!!!"

foldl1ist schwanzrekursiv, während foldr1eine geschützte Rekursion durchgeführt wird, so dass das erste Element sofort zur weiteren Verarbeitung / zum weiteren Zugriff präsentiert wird. (Die erste "Klammer" auf der linken Seite wird sofort verwendet, (...((s+s)+s)+...)+swodurch die Eingabeliste vollständig auf das Ende gedrängt wird und viel früher als die vollständigen Ergebnisse erstellt werden. Die zweite Klammer wird nach und nach in Klammern gesetzt, wodurch die Eingabe s+(s+(...+(s+s)...))verbraucht wird Liste Stück für Stück auf, damit das Ganze mit Optimierungen in konstantem Raum arbeiten kann).

Möglicherweise müssen Sie die Anzahl der Nullen anpassen, je nachdem, welche Hardware Sie verwenden.

AndrewC
quelle
1
@ WillNess Das ist ausgezeichnet, danke. kein Einfahren erforderlich. Ich denke, es ist jetzt eine bessere Antwort für die Nachwelt.
AndrewC
4
Das ist großartig, aber darf ich eine Anspielung auf die Strenge-Analyse vorschlagen ? Ich denke, das wird mit ziemlicher Sicherheit die Aufgabe für die schwanzrekursive Fakultät in jeder einigermaßen neueren Version von GHC erfüllen.
dfeuer
15

Es sollte erwähnt werden, dass die facFunktion kein guter Kandidat für eine vorsichtige Rekursion ist. Schwanzrekursion ist der Weg hierher. Aufgrund der Faulheit erhalten Sie in Ihrer fac'Funktion nicht die Wirkung von TCO, da die Akkumulatorargumente immer wieder große Thunks bilden, für deren Auswertung ein großer Stapel erforderlich ist. Um dies zu verhindern und den gewünschten Effekt von TCO zu erzielen, müssen Sie diese Akkumulatorargumente streng machen.

{-# LANGUAGE BangPatterns #-}

fac :: (Integral a) => a -> a
fac x = fac' x 1 where
  fac' 1  y = y
  fac' x !y = fac' (x-1) (x*y)

Wenn Sie mit -O2(oder nur -O) GHC kompilieren, wird dies wahrscheinlich in der Phase der Strenge-Analyse selbst durchgeführt .

is7s
quelle
4
Ich denke, es ist klarer mit $!als mit BangPatterns, aber das ist eine gute Antwort. Besonders die Erwähnung der Strenge-Analyse.
Singpolym
7

Sie sollten den Wiki-Artikel über die Schwanzrekursion in Haskell lesen . Insbesondere aufgrund der Ausdrucksbewertung ist die Art der gewünschten Rekursion eine geschützte Rekursion. Wenn Sie die Details herausarbeiten, was unter der Haube vor sich geht (in der abstrakten Maschine für Haskell), erhalten Sie das Gleiche wie bei der Schwanzrekursion in strengen Sprachen. Darüber hinaus haben Sie eine einheitliche Syntax für Lazy-Funktionen (die Schwanzrekursion bindet Sie an eine strenge Bewertung, während die geschützte Rekursion natürlicher funktioniert).

(Und beim Erlernen von Haskell sind auch die restlichen Wiki-Seiten fantastisch!)

Kristopher Micinski
quelle
0

Wenn ich mich richtig erinnere, optimiert GHC einfach rekursive Funktionen automatisch in schwanzrekursiv optimierte.

Ncat
quelle