Welche Eigenschaft der Nachteile ermöglicht die Eliminierung der modulo Nachteile der Schwanzrekursion?

14

Ich bin mit der Idee der grundlegenden Beseitigung der Schwanzrekursion vertraut , bei der Funktionen, die das direkte Ergebnis eines Aufrufs an sich selbst zurückgeben, als iterative Schleifen umgeschrieben werden können.

foo(...):
    # ...
    return foo(...)

Ich verstehe auch, dass die Funktion als Sonderfall immer noch umgeschrieben werden kann, wenn der rekursive Aufruf in einen Aufruf an eingeschlossen wird cons.

foo(...):
    # ...
    return (..., foo(...))

Welche Eigenschaft conserlaubt dies? Welche anderen Funktionen conskönnen einen rekursiven Tail-Aufruf umbrechen, ohne unsere Fähigkeit zu beeinträchtigen, ihn iterativ umzuschreiben?

GCC (aber nicht Clang) ist in der Lage, dieses Beispiel der "Tail Recursion Modulo Multiplication " zu optimieren , aber es ist unklar, mit welchem ​​Mechanismus es dies entdeckt oder wie es seine Transformationen durchführt.

pow(x, n):
    if n == 0: return 1
    else if n == 1: return x
    else: return x * pow(x, n-1)
Maxpm
quelle
1
In Ihrem Godbolt-Compiler-Explorer-Link hat Ihre Funktion if(n==0) return 0;(nicht 1 wie in Ihrer Frage). x^0 = 1Das ist also ein Fehler. Nicht, dass es für den Rest der Frage von Bedeutung wäre; Der iterative ASM sucht zuerst nach diesem Sonderfall. Aber seltsamerweise führt die iterative Implementierung ein Vielfaches dessen ein 1 * x, was in der Quelle nicht vorhanden war, selbst wenn wir eine floatVersion erstellen. gcc.godbolt.org/z/eqwine (und gcc gelingt nur mit -ffast-math.)
Peter Cordes
@ PeterCordes Guter Fang. Das return 0wurde behoben. Die Multiplikation mit 1 ist interessant. Ich bin mir nicht sicher, was ich davon halten soll.
Maxpm
Ich denke, es ist ein Nebeneffekt der Art und Weise, wie sich GCC in eine Schleife verwandelt. Offensichtlich gcc hat einige Optimierungen verpasst hier zB es für vermisste floatohne -ffast-math, auch wenn es der gleiche Wert jedes Mal multipliziert wird. (Mit Ausnahme der 1.0f, die der Knackpunkt sein könnte?)
Peter Cordes

Antworten:

12

Während GCC wahrscheinlich Ad-hoc-Regeln verwendet, können Sie diese auf folgende Weise ableiten. Ich werde es powzur Veranschaulichung verwenden, da du fooso vage definiert bist . Dies kann fooauch am besten als eine Instanz der Last-Call-Optimierung in Bezug auf Einzelzuweisungsvariablen verstanden werden, wie sie die Sprache Oz hat und wie sie in Konzepte, Techniken und Modelle der Computerprogrammierung erörtert wird . Der Vorteil der Verwendung von Variablen mit einfacher Zuweisung besteht darin, dass ein deklaratives Programmierparadigma beibehalten werden kann. Grundsätzlich kann jedes Feld der Strukturrückgabe foodurch Variablen mit einfacher Zuweisung dargestellt werden, die dann fooals zusätzliche Argumente übergeben werden. foodann wird ein Schwanz rekursivvoidRückgabefunktion. Hierfür ist keine besondere Klugheit erforderlich.

Kehren wir powzunächst zu dem Stil der Weitergabe zurück . powwird:

pow(x, n):
    return pow2(x, n, x => x)

pow2(x, n, k):
    if n == 0: return k(1)
    else if n == 1: return k(x)
    else: return pow2(x, n-1, y => k(x*y))

Alle Anrufe sind jetzt Tail Calls. Der Kontrollstapel wurde jedoch in die erfassten Umgebungen in den Verschlüssen verschoben, die die Fortsetzungen darstellen.

Als nächstes defunktionalisieren Sie die Fortsetzungen. Da es nur einen rekursiven Aufruf gibt, ist die resultierende Datenstruktur, die die defunktionalisierten Fortsetzungen darstellt, eine Liste. Wir bekommen:

pow(x, n):
    return pow2(x, n, Nil)

pow2(x, n, k):
    if n == 0: return applyPow(k, 1)
    else if n == 1: return applyPow(k, x)
    else: return pow2(x, n-1, Cons(x, k))

applyPow(k, acc):
    match k with:
        case Nil: return acc
        case Cons(x, k):
            return applyPow(k, x*acc)

Was applyPow(k, acc)tut, ist eine Liste, dh freie Monoide, zu nehmen k=Cons(x, Cons(x, Cons(x, Nil)))und daraus zu machen x*(x*(x*acc)). Aber da *es assoziativ ist und im Allgemeinen ein Monoid mit der Einheit bildet 1, können wir dies wieder in die Produktion einordnen ((x*x)*x)*accund der Einfachheit halber 1beginnen (((1*x)*x)*x)*acc. Der Schlüssel ist, dass wir das Ergebnis tatsächlich teilweise berechnen können, noch bevor wir es haben acc. Das heißt, anstatt kals Liste herumzulaufen, die im Wesentlichen eine unvollständige "Syntax" ist, die wir am Ende "interpretieren", können wir sie "interpretieren", während wir gehen. Das Ergebnis ist, dass wir in diesem Fall durch Nildie Einheit des Monoids 1und Consdurch die Operation des Monoids ersetzen können *und nun kdas "laufende Produkt" darstellen.applyPow(k, acc)Dann wird genau das, k*accwas wir wieder einbinden pow2und das Produzieren vereinfachen können:

pow(x, n):
    return pow2(x, n, 1)

pow2(x, n, k):
    if n == 0: return k
    else if n == 1: return k*x
    else: return pow2(x, n-1, k*x)

Eine rekursive Version des Originals im Akkumulator-Passing-Stil pow.

Natürlich sage ich nicht, dass GCC all diese Überlegungen zur Kompilierungszeit ausführt. Ich weiß nicht, welche Logik GCC verwendet. Mein Punkt ist einfach, diese Überlegung einmal gemacht zu haben, es ist relativ einfach, das Muster zu erkennen und den ursprünglichen Quellcode sofort in diese endgültige Form zu übersetzen. Die CPS-Transformation und die Defunktionalisierungstransformation sind jedoch vollständig allgemein und mechanisch. Von dort aus könnten Fusions-, Entwaldungs- oder Superkompilierungstechniken verwendet werden, um zu versuchen, die wiedervereinigten Fortsetzungen zu beseitigen. Die spekulativen Transformationen könnten verworfen werden, wenn es nicht möglich ist, die gesamte Zuordnung der wiedervereinigten Fortsetzungen aufzuheben. Ich vermute jedoch, dass dies zu teuer wäre, um es die ganze Zeit über zu tun, und daher mehr Ad-hoc-Ansätze.

Wenn Sie sich lächerlich machen möchten, können Sie die Papierrecycling- Fortsetzungen durchlesen, die ebenfalls CPS und Darstellungen von Fortsetzungen als Daten verwenden, jedoch etwas Ähnliches tun, das sich von den Schwanzrekursions-Modulo-Kons unterscheidet. Hier wird beschrieben, wie Sie durch Transformation Pointer-Reversing-Algorithmen erzeugen können.

Dieses Muster der CPS-Transformation und -Defunktionalisierung ist ein ziemlich mächtiges Werkzeug zum Verständnis und wird in einer Reihe von Arbeiten, die ich hier aufführe, effektiv eingesetzt .

Derek Elkins verließ SE
quelle
Die Technik, die GCC anstelle des Continuation-Passing-Stils verwendet, den Sie hier zeigen, ist meines Erachtens eine statische Einzelzuweisungsform.
Davislor
@Davislor Im Zusammenhang mit CPS beeinflusst SSA weder den Steuerungsfluss einer Prozedur noch ändert es den Stack (oder führt auf andere Weise Datenstrukturen ein, die dynamisch zugewiesen werden müssten). Im Zusammenhang mit SSA macht CPS "zu viel", weshalb die administrative Normalform (ANF) der SSA besser entspricht. Daher verwendet GCC SSA, aber SSA führt nicht dazu, dass der Steuerungsstapel als manipulierbare Datenstruktur angezeigt wird.
Derek Elkins verließ SE
Richtig. Ich antwortete: „Ich sage nicht, dass GCC all diese Überlegungen zur Kompilierungszeit ausführt. Ich weiß nicht, welche Logik GCC verwendet. “Meine Antwort zeigte ebenfalls, dass die Transformation theoretisch gerechtfertigt ist, ohne zu sagen, dass es sich um die Implementierungsmethode handelt, die ein bestimmter Compiler verwendet. (Obwohl, wie Sie wissen, viele Compiler während der Optimierung ein Programm in CPS
umwandeln
8

Ich werde eine Weile um den heißen Brei herumreden, aber es gibt einen Grund.

Halbgruppen

Die Antwort ist die assoziative Eigenschaft der binären Reduktionsoperation .

Das ist ziemlich abstrakt, aber Multiplikation ist ein gutes Beispiel. Wenn x , y und z natürliche Zahlen (oder ganze Zahlen oder rationale Zahlen oder reelle Zahlen oder komplexe Zahlen oder N × N Matrizen oder eine ganze Reihe weiterer Dinge) sind, dann ist x × y dieselbe Art der Zahl als x und y . Wir haben mit zwei Zahlen begonnen, es ist also eine binäre Operation, und wir haben eine bekommen, also haben wir die Anzahl der Zahlen, die wir hatten, um eins reduziert, was dies zu einer Reduktionsoperation macht. Und ( x × y ) × z ist immer dasselbe wie x × ( y ×z ), welches die assoziative Eigenschaft ist.

(Wenn Sie dies alles bereits wissen, können Sie mit dem nächsten Abschnitt fortfahren.)

Noch ein paar Dinge, die Sie in der Informatik häufig sehen und die auf dieselbe Weise funktionieren:

  • Hinzufügen einer dieser Arten von Zahlen, anstatt zu multiplizieren
  • Verketten von Zeichenfolgen ( "a"+"b"+"c"ist, "abc"ob Sie mit "ab"+"c"oder beginnen "a"+"bc")
  • Zwei Listen zusammenfügen. [a]++[b]++[c]ist ähnlich [a,b,c]entweder von hinten nach vorne oder von vorne nach hinten.
  • consAuf Kopf und Schwanz, wenn Sie sich den Kopf als Singleton-Liste vorstellen. Das verkettet nur zwei Listen.
  • Nehmen der Vereinigung oder der Schnittmenge von Mengen
  • Boolean und, Boolean oder
  • bitweise &, |und^
  • Zusammensetzung der Funktionen: ( fg ) ∘ h x = f g ( gh ) x = f ( g ( h ( x ))
  • Maximum und Minimum
  • Zusatz modulo p

Einige Dinge, die dies nicht tun:

  • Subtraktion, weil 1- (1-2) ≠ (1-1) -2
  • xy = tan ( x + y ), weil tan (π / 4 + π / 4) undefiniert ist
  • Multiplikation über die negativen Zahlen, da -1 × -1 keine negative Zahl ist
  • Division von ganzen Zahlen, die alle drei Probleme hat!
  • logisch nicht, weil es nur einen Operanden hat, nicht zwei
  • int print2(int x, int y) { return printf( "%d %d\n", x, y ); }, as print2( print2(x,y), z );und print2( x, print2(y,z) );haben unterschiedliche Ausgabe.

Es ist ein ausreichend nützliches Konzept, das wir es genannt haben. Eine Menge mit einer Operation, die diese Eigenschaften aufweist, ist eine Halbgruppe . Die reellen Zahlen unter Multiplikation sind also eine Halbgruppe. Und Ihre Frage erweist sich als eine der Möglichkeiten, wie diese Art der Abstraktion in der realen Welt nützlich wird. Der Betrieb von Halbgruppen kann nach Ihren Wünschen optimiert werden.

Versuchen Sie dies zu Hause

Soweit ich weiß, wurde diese Technik 1974 erstmals in Daniel Friedmans und David Wises Aufsatz "Folding Stylized Recursions into Iterations" beschrieben , obwohl sie ein paar Eigenschaften mehr annahmen, als es sich als notwendig herausstellte.

Haskell ist eine großartige Sprache, um dies zu veranschaulichen, da die SemigroupTypenklasse in der Standardbibliothek enthalten ist. Es nennt die Bedienung eines Generikums Semigroupden Operator <>. Da Listen und Zeichenfolgen Instanzen von sind Semigroup, definieren ihre Instanzen <>beispielsweise den Verkettungsoperator ++. Und mit dem richtigen Import, [a] <> [b]ist ein Alias ​​für [a] ++ [b], was ist [a,b].

Aber was ist mit Zahlen? Wir haben gerade gesehen, dass numerische Typen Halbgruppen sind, die entweder addiert oder multipliziert werden! Also was darf man <>für ein sein Double? Na ja, einer von beiden! Haskell definiert die Arten Product Double, where (<>) = (*)(dass die tatsächliche Definition in Haskell ist), und auch Sum Double, where (<>) = (+).

Eine Falte ist, dass Sie die Tatsache verwendet haben, dass 1 die multiplikative Identität ist. Eine Halbgruppe mit einer Identität wird als Monoid bezeichnet und im Haskell-Paket definiert Data.Monoid, das das generische Identitätselement einer Typenklasse aufruft mempty. Sum, Productund list haben jeweils ein Identitätselement (0, 1 []bzw.), sie sind also Instanzen von Monoidsowie Semigroup. (Nicht zu verwechseln mit einer Monade , also vergiss einfach, dass ich diese sogar angesprochen habe.)

Das sind genug Informationen, um Ihren Algorithmus mit Hilfe von Monoiden in eine Haskell-Funktion zu übersetzen:

module StylizedRec (pow) where

import Data.Monoid as DM

pow :: Monoid a => a -> Word -> a
{- Applies the monoidal operation of the type of x, whatever that is, by
 - itself n times.  This is already in Haskell as Data.Monoid.mtimes, but
 - let’s write it out as an example.
 -}
pow _ 0 = mempty -- Special case: Return the nullary product.
pow x 1 = x      -- The base case.
pow x n = x <> (pow x (n-1)) -- The recursive case.

Es ist wichtig zu beachten, dass dies eine Modulo-Halbgruppe mit Endrekursion ist: Jeder Fall ist entweder ein Wert, ein Endrekursionsaufruf oder das Halbgruppenprodukt von beiden. Auch dieses Beispiel wurde memptyfür einen der Fälle verwendet, aber wenn wir das nicht gebraucht hätten, hätten wir es mit der allgemeineren Typenklasse machen können Semigroup.

Laden wir dieses Programm in GHCI und sehen, wie es funktioniert:

*StylizedRec> getProduct $ pow 2 4
16
*StylizedRec> getProduct $ pow 7 2
49

Erinnern Sie sich, wie wir powfür ein Generikum deklariert haben Monoid, dessen Typ wir genannt haben a? Wir gaben GHCI genug Informationen abzuleiten , dass der Typ ahier ist Product Integer, die eine ist instancevon Monoidderen <>Betrieb Integer - Multiplikation ist. So pow 2 4dehnt sich rekursiv auf 2<>2<>2<>2, was ist 2*2*2*2oder 16. So weit, ist es gut.

Unsere Funktion verwendet jedoch nur generische Monoid-Operationen. Zuvor habe ich gesagt, dass es eine andere Instanz von Monoidcalled gibt Sum, deren <>Operation ist +. Können wir das versuchen?

*StylizedRec> getSum $ pow 2 4
8
*StylizedRec> getSum $ pow 7 2
14

Die gleiche Erweiterung gibt uns jetzt 2+2+2+2statt 2*2*2*2. Multiplikation ist Addition, Exponentiation ist Multiplikation!

Aber ich habe ein anderes Beispiel für ein Haskell-Monoid gegeben: Listen, deren Operation Verkettung ist.

*StylizedRec> pow [2] 4
[2,2,2,2]
*StylizedRec> pow [7] 2
[7,7]

Das Schreiben [2]teilt dem Compiler mit, dass dies eine Liste ist, <>auf Listen ist ++, so [2]++[2]++[2]++[2]ist es [2,2,2,2].

Schließlich ein Algorithmus (zwei, in der Tat)

Durch einfaches Ersetzen xdurch [x]konvertieren Sie den generischen Algorithmus, der das Rekursionsmodul einer Halbgruppe verwendet, in einen Algorithmus, der eine Liste erstellt. Welche Liste? Die Liste der Elemente, auf die der Algorithmus angewendet wird <>. Da wir nur Halbgruppenoperationen verwendet haben, die auch Listen enthalten, ist die resultierende Liste isomorph zur ursprünglichen Berechnung. Und da die ursprüngliche Operation assoziativ war, können wir die Elemente genauso gut von hinten nach vorne oder von vorne nach hinten bewerten.

Wenn Ihr Algorithmus jemals einen Basisfall erreicht und endet, ist die Liste nicht leer. Da der Terminal-Fall etwas zurückgegeben hat, ist dies das letzte Element der Liste, sodass mindestens ein Element enthalten ist.

Wie wendet man eine binäre Reduktionsoperation auf jedes Element einer Liste in der angegebenen Reihenfolge an? Das ist richtig, eine Falte. So können Sie ersetzen können , [x]für x, zu reduzieren , indem eine Liste von Elementen erhalten <>, und dann entweder mit dem rechten oder linken fach-fach der Liste:

*StylizedRec> getProduct $ foldr1 (<>) $ pow [Product 2] 4
16
*StylizedRec> import Data.List
*StylizedRec Data.List> getProduct $ foldl1' (<>) $ pow [Product 2] 4
16

Die Version mit foldr1existiert tatsächlich in der Standardbibliothek, wie sconcatfür Semigroupund mconcatfür Monoid. Es macht eine faule rechte Falte auf der Liste. Das heißt, es erweitert sich [Product 2,Product 2,Product 2,Product 2]zu 2<>(2<>(2<>(2))).

Dies ist in diesem Fall nicht effizient, da Sie mit den einzelnen Begriffen erst dann etwas anfangen können, wenn Sie alle generieren. (Irgendwann hatte ich hier eine Diskussion darüber, wann man rechte und wann man strenge linke Falten verwendet, aber es ging zu weit.)

Die Version mit foldl1'ist eine streng bewertete Linksfalte. Das heißt, eine schwanzrekursive Funktion mit einem strengen Akkumulator. Dies (((2)<>2)<>2)<>2wird sofort berechnet und nicht später, wenn es benötigt wird. (Zumindest gibt es keine Verzögerungen innerhalb der Falte selbst: Die Liste, die gefaltet wird, wird hier von einer anderen Funktion generiert, die möglicherweise eine verzögerte Auswertung enthält.) Die Falte berechnet (4<>2)<>2dann sofort und berechnet 8<>2dann 16. Aus diesem Grund musste die Operation assoziativ sein: Wir haben nur die Gruppierung der Klammern geändert!

Die strikte linke Falte entspricht dem, was GCC tut. Die am weitesten links stehende Zahl im vorherigen Beispiel ist der Akku, in diesem Fall ein laufendes Produkt. Bei jedem Schritt wird es mit der nächsten Zahl in der Liste multipliziert. Eine andere Möglichkeit, dies auszudrücken, besteht darin, dass Sie über die zu multiplizierenden Werte iterieren, das laufende Produkt in einem Akkumulator halten und bei jeder Iteration den Akkumulator mit dem nächsten Wert multiplizieren. Das heißt, es ist eine whileVerkleidungsschleife.

Es kann manchmal genauso effizient gemacht werden. Der Compiler ist möglicherweise in der Lage, die Listendatenstruktur im Speicher zu optimieren. Theoretisch verfügt es zur Kompilierungszeit über genügend Informationen, um herauszufinden, wie dies hier [x]geschehen sollte: Ist ein Singleton, [x]<>xsist also dasselbe wie cons x xs. Bei jeder Iteration der Funktion kann möglicherweise derselbe Stack-Frame wiederverwendet und die Parameter aktualisiert werden.

Entweder eine rechte oder eine strenge linke Falte ist in bestimmten Fällen besser geeignet. Wissen Sie also, welche Sie möchten. Es gibt auch einige Dinge, die nur eine richtige Falte tun kann (z. B. interaktive Ausgabe generieren, ohne auf die gesamte Eingabe zu warten, und eine unendliche Liste bearbeiten). Hier reduzieren wir jedoch eine Abfolge von Operationen auf einen einfachen Wert, sodass wir eine strenge linke Falte wünschen.

Wie Sie sehen, ist es also möglich, das Modulo der Schwanzrekursion für jede Halbgruppe (ein Beispiel hierfür ist einer der üblichen numerischen Typen unter Multiplikation) automatisch in eine träge Rechtsfalte oder eine strenge Linksfalte in einer Zeile von zu optimieren Haskell.

Weitere Verallgemeinerungen

Die beiden Argumente der Binäroperation müssen nicht vom selben Typ sein, solange der Anfangswert vom selben Typ ist wie Ihr Ergebnis. (Sie können die Argumente natürlich immer umdrehen, um sie an die Reihenfolge der Falzarten anzupassen, die Sie ausführen (links oder rechts).) Sie können einer Datei also wiederholt Patches hinzufügen, um eine aktualisierte Datei zu erhalten, oder mit dem Anfangswert von beginnen 1,0, durch ganze Zahlen dividieren, um ein Gleitkommaergebnis zu erhalten. Oder stellen Sie der leeren Liste Elemente voran, um eine Liste zu erhalten.

Eine andere Art der Verallgemeinerung besteht darin, die Falten nicht auf Listen, sondern auf andere FoldableDatenstrukturen anzuwenden . Häufig entspricht eine unveränderliche lineare verknüpfte Liste nicht der Datenstruktur, die Sie für einen bestimmten Algorithmus wünschen. Ein Problem, auf das ich oben nicht eingegangen bin, ist, dass das Hinzufügen von Elementen an der Vorderseite einer Liste viel effizienter ist als an der Rückseite. Wenn die Operation nicht kommutativ ist, gilt dies nicht xfür die linke und rechte Seite der Operation das Gleiche. Sie müssten also eine andere Struktur verwenden, z. B. ein Listenpaar oder einen Binärbaum, um einen Algorithmus darzustellen, xder sowohl rechts <>als auch links angewendet werden kann.

Beachten Sie auch, dass Sie mit der assoziativen Eigenschaft die Operationen auf andere nützliche Arten neu gruppieren können, z. B. durch Teilen und Erobern:

times :: Monoid a => a -> Word -> a
times _ 0 = mempty
times x 1 = x
times x n | even n    = y <> y
          | otherwise = x <> y <> y
  where y = times x (n `quot` 2)

Oder automatische Parallelität, bei der jeder Thread einen Unterbereich auf einen Wert reduziert, der dann mit den anderen kombiniert wird.

Davislor
quelle
1
Wir können ein Experiment Test machen , dass die Assoziativität der Schlüssel zum GCC Fähigkeit ist diese Optimierung zu tun: eine pow(float x, unsigned n)Version gcc.godbolt.org/z/eqwine optimiert nur mit -ffast-math(was impliziert -fassociative-math. Strenge Gleitkomma ist natürlich nicht assoziativ , weil verschiedene Provisorien = andere Rundung). Das führt ein 1.0f * x, das in der abstrakten C-Maschine nicht vorhanden war (aber immer ein identisches Ergebnis liefert). Dann sind n-1-Multiplikationen wie do{res*=x;}while(--n!=1)die rekursiven, so dass dies eine fehlende Optimierung ist.
Peter Cordes