Quali proprietà dei contro consentono l'eliminazione dei contro modulo di ricorsione della coda?


14

Conosco l'idea dell'eliminazione di base della ricorsione della coda, in cui le funzioni che restituiscono il risultato diretto di una chiamata a se stesse possono essere riscritte come cicli iterativi.

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

Comprendo anche che, come caso speciale, la funzione può ancora essere riscritta se la chiamata ricorsiva è racchiusa in una chiamata a cons.

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

Quale proprietà di consconsente questo? Quali funzioni diverse da quelle conspossono avvolgere una chiamata di coda ricorsiva senza distruggere la nostra capacità di riscriverla in modo iterativo?

GCC (ma non Clang) è in grado di ottimizzare questo esempio di " moltiplicazione del modulo di ricorsione della coda" , ma non è chiaro quale meccanismo gli permetta di scoprire questo o come faccia le sue trasformazioni.

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

1
Nel tuo link esploratore compilatore Godbolt, la tua funzione ha if(n==0) return 0;(non restituire 1 come nella tua domanda). x^0 = 1, quindi è un bug. Non che sia importante per il resto della domanda, comunque; l'aser iterativo controlla prima quel caso speciale. Ma stranamente, l'implementazione iterativa introduce una moltiplicazione di 1 * xciò che non era presente nella fonte, anche se ne facciamo una floatversione. gcc.godbolt.org/z/eqwine (e gcc riesce solo con -ffast-math.)
Peter Cordes

@PeterCordes Buona cattura. Il return 0problema è stato risolto. La moltiplicazione per 1 è interessante. Non sono sicuro di cosa farne.
Max

Penso che sia un effetto collaterale del modo in cui GCC si trasforma quando lo trasforma in un ciclo. Chiaramente gcc ha alcune ottimizzazioni mancate qui, ad esempio mancando per floatsenza -ffast-math, anche se lo stesso valore viene moltiplicato ogni volta. (Tranne la 1.0f` quale potrebbe essere il punto critico?)
Peter Cordes,

Risposte:


12

Sebbene GCC probabilmente usi regole ad hoc, puoi derivarle nel modo seguente. Lo userò powper illustrare poiché sei foocosì vagamente definito. Inoltre, foopotrebbe essere meglio inteso come un'istanza di ottimizzazione dell'ultima chiamata rispetto alle variabili a singola assegnazione come il linguaggio Oz ha e come discusso in Concetti, tecniche e modelli di programmazione del computer . Il vantaggio dell'utilizzo di variabili a assegnazione singola è che consente di rimanere all'interno di un paradigma di programmazione dichiarativo. In sostanza, è possibile avere ciascun campo dei foorendimenti della struttura rappresentato da variabili a singola assegnazione che vengono quindi passate foocome argomenti aggiuntivi. fooquindi diventa ricorsivo di codavoidfunzione di ritorno. Non è necessaria una particolare intelligenza per questo.

Ritornando pow, in primo luogo, si trasforma in continuazione passando per stile . powdiventa:

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))

Tutte le chiamate sono chiamate di coda ora. Tuttavia, lo stack di controllo è stato spostato negli ambienti acquisiti nelle chiusure che rappresentano le continuazioni.

Quindi, defunzionalizzare le continuazioni. Poiché esiste solo una chiamata ricorsiva, la struttura di dati risultante che rappresenta le continuazioni defunzionalizzate è un elenco. Noi abbiamo:

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)

Ciò che applyPow(k, acc)fa è prendere un elenco, cioè un monoide gratuito, come k=Cons(x, Cons(x, Cons(x, Nil)))e trasformarlo x*(x*(x*acc)). Ma poiché *è associativo e generalmente forma un monoide con unità 1, possiamo riassociarlo in ((x*x)*x)*acc, e, per semplicità, attaccare 1per iniziare, producendo (((1*x)*x)*x)*acc. La cosa fondamentale è che possiamo effettivamente calcolare parzialmente il risultato anche prima di averlo acc. Ciò significa che invece di passare in krassegna come un elenco che è essenzialmente una "sintassi" incompleta che "interpreteremo" alla fine, possiamo "interpretarlo" mentre procediamo. Il risultato è che possiamo sostituire Nilcon l'unità del monoide, 1in questo caso, e Conscon il funzionamento del monoide *, e ora krappresenta il "prodotto in esecuzione".applyPow(k, acc)poi diventa proprio k*accciò in cui possiamo ricollegarci pow2e semplificare la produzione:

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)

Una versione originale ricorsiva della coda, che passa attraverso l'accumulatore pow.

Naturalmente, non sto dicendo che GCC faccia tutto questo ragionamento in fase di compilazione. Non so quale logica utilizzi GCC. Il mio punto è semplicemente aver fatto questo ragionamento una volta, è relativamente facile riconoscere lo schema e tradurre immediatamente il codice sorgente originale in questo modulo finale. Tuttavia, la trasformazione CPS e la trasformazione di defunzionalizzazione sono completamente generali e meccaniche. Da lì si potrebbero usare tecniche di fusione, deforestazione o supercompilazione per tentare di eliminare le continuazioni reificate. Le trasformazioni speculative potrebbero essere eliminate se non è possibile eliminare tutta l'assegnazione delle continuazioni reificate. Sospetto, tuttavia, che sarebbe troppo costoso fare tutto il tempo, in generale, quindi approcci più ad hoc.

Se vuoi diventare ridicolo, puoi dare un'occhiata al documento Continuazioni sul riciclaggio che utilizza anche CPS e rappresentazioni delle continuazioni come dati, ma fa qualcosa di simile ma diverso dal modulo di ricorsione della coda. Descrive come è possibile produrre algoritmi di inversione del puntatore mediante trasformazione.

Questo modello di trasformazione e defunzionalizzazione di CPS è uno strumento abbastanza potente per la comprensione, ed è usato con buoni risultati in una serie di articoli che elencherò qui .


La tecnica che GCC utilizza al posto dello stile di continuazione che mostri qui è, credo, il modulo statico di assegnazione singola.
Davislor,

@Davislor Sebbene correlato a CPS, SSA non influisce sul flusso di controllo di una procedura né reificazione dello stack (o altrimenti introduce strutture di dati che dovrebbero essere allocate dinamicamente). Per quanto riguarda SSA, CPS "fa troppo", motivo per cui il modulo amministrativo normale (ANF) si adatta meglio a SSA. Quindi GCC utilizza SSA, ma SSA non consente di visualizzare lo stack di controllo come una struttura di dati manipolabile.
Derek Elkins lasciò SE il

Giusto. Stavo rispondendo: “Non sto dicendo che GCC fa tutto questo ragionamento in fase di compilazione. Non so quale logica utilizzi GCC. ”La mia risposta, allo stesso modo, stava dimostrando che la trasformazione è teoricamente giustificata, senza dire che è il metodo di implementazione utilizzato da un determinato compilatore. (Sebbene, come sapete, molti compilatori trasformano un programma in CPS durante l'ottimizzazione.)
Davislor,

8

Sto andando a battere il cespuglio per un po ', ma c'è un punto.

semigruppi

La risposta è la proprietà associativa dell'operazione di riduzione binaria .

È piuttosto astratto, ma la moltiplicazione è un buon esempio. Se x , y e z sono dei numeri naturali (o interi o numeri razionali, o numeri reali o numeri complessi o N × N matrici o qualsiasi di un sacco altre cose), allora x × y è lo stesso tipo di numero come x e y . Abbiamo iniziato con due numeri, quindi è un'operazione binaria e ne abbiamo ottenuta una, quindi abbiamo ridotto il numero di numeri che avevamo di uno, rendendo l'operazione di riduzione. E ( x × y ) × z è sempre uguale a x × ( y ×z ), che è la proprietà associativa.

(Se conosci già tutto questo, puoi passare alla sezione successiva.)

Alcune altre cose che vedi spesso in informatica che funzionano allo stesso modo:

  • aggiungendo uno di quei tipi di numeri invece di moltiplicarli
  • concatenare stringhe ( "a"+"b"+"c"è "abc"se inizi con "ab"+"c"o "a"+"bc")
  • Unire due liste insieme. [a]++[b]++[c]è allo stesso [a,b,c]modo da dietro a davanti o da davanti a dietro.
  • conssu una testa e una coda, se pensi alla testa come a una lista singleton. Questo è solo concatenare due elenchi.
  • prendendo l'unione o l'intersezione di insiemi
  • Booleano e, Booleano o
  • bit a bit &, |e^
  • composizione delle funzioni: ( fg ) ∘ h x = f ∘ ( gh ) x = f ( g ( h ( x )))
  • massimo e minimo
  • aggiunta modulo p

Alcune cose che non lo fanno:

  • sottrazione, perché 1- (1-2) ≠ (1-1) -2
  • xy = tan ( x + y ), perché tan (π / 4 + π / 4) non è definito
  • moltiplicazione sui numeri negativi, perché -1 × -1 non è un numero negativo
  • divisione di numeri interi, che presenta tutti e tre i problemi!
  • logico no, perché ha un solo operando, non due
  • int print2(int x, int y) { return printf( "%d %d\n", x, y ); }, come print2( print2(x,y), z );e print2( x, print2(y,z) );hanno output diverso.

È un concetto abbastanza utile che l'abbiamo chiamato. Un set con un'operazione che ha queste proprietà è un semigruppo . Quindi, i numeri reali sotto moltiplicazione sono un semigruppo. E la tua domanda si rivela essere uno dei modi in cui questo tipo di astrazione diventa utile nel mondo reale. Le operazioni di un semigruppo possono essere tutte ottimizzate nel modo richiesto.

Prova questo a casa

Per quanto ne so, questa tecnica è stata descritta per la prima volta nel 1974, nel documento di Daniel Friedman e David Wise, "Ripiegare le ricorsioni stilizzate in iterazioni" , sebbene abbiano assunto alcune proprietà in più rispetto a quanto risultasse necessario.

Haskell è un ottimo linguaggio per illustrarlo, perché ha la Semigrouptypeclass nella sua libreria standard. Chiama l'operazione di un generico Semigroupl'operatore <>. Poiché gli elenchi e le stringhe sono istanze di Semigroup, le loro istanze definiscono <>l'operatore di concatenazione ++, ad esempio. E con la giusta importazione, [a] <> [b]è un alias per [a] ++ [b], che è [a,b].

Ma che dire dei numeri? Abbiamo appena visto che i tipi numerici sono semigruppi sotto sia aggiunta o la moltiplicazione! Quindi quale si ottiene <>per un Double? Bene, uno dei due! Haskell definisce i tipi Product Double, where (<>) = (*)(che è la definizione stessa in Haskell), e anche Sum Double, where (<>) = (+).

Una ruga è che hai usato il fatto che 1 è l'identità moltiplicativa. Un semigruppo con un'identità è chiamato monoid ed è definito nel pacchetto Haskell Data.Monoid, che chiama l'elemento di identità generico di una typeclass mempty. Sum, ProductE la lista ciascuno ha un elemento di identità (0, 1 e [], rispettivamente), quindi sono esempi di Monoidcome Semigroup. (Da non confondere con una monade , quindi dimentica anche di averli menzionati.)

Sono sufficienti informazioni per tradurre l'algoritmo in una funzione di Haskell usando i monoidi:

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.

È importante sottolineare che si tratta del semigruppo modulo ricorsione di coda: ogni caso è un valore, una chiamata ricorsiva di coda o il prodotto semigruppo di entrambi. Inoltre, questo esempio è stato usato memptyper uno dei casi, ma se non ne avessimo avuto bisogno, avremmo potuto farlo con la tabella dei tipi più generale Semigroup.

Cariciamo questo programma in GHCI e vediamo come funziona:

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

Ricordi come abbiamo dichiarato powper un generico Monoid, di chi abbiamo chiamato a? Abbiamo fornito a GHCI informazioni sufficienti per dedurre che il tipo aqui è Product Integer, che è una instancedelle Monoidcui <>operazioni è la moltiplicazione intera. Quindi si pow 2 4espande in modo ricorsivo a 2<>2<>2<>2, che è 2*2*2*2o 16. Fin qui tutto bene.

Ma la nostra funzione utilizza solo operazioni monoid generiche. In precedenza, ho detto che esiste un'altra istanza di Monoidchiamata Sum, la cui <>operazione è +. Possiamo provarlo?

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

La stessa espansione ora ci dà 2+2+2+2invece di 2*2*2*2. La moltiplicazione è aggiunta come l'espiazione è la moltiplicazione!

Ma ho dato un altro esempio di un monoide di Haskell: liste, la cui operazione è la concatenazione.

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

La scrittura [2]dice al compilatore che questa è una lista, <>su liste è ++, così [2]++[2]++[2]++[2]è [2,2,2,2].

Finalmente un algoritmo (due, in realtà)

Sostituendo semplicemente xcon [x], si converte l'algoritmo generico che utilizza la ricorsione modulo un semigruppo in uno che crea un elenco. Quale lista? L'elenco degli elementi a cui si applica l'algoritmo <>. Poiché abbiamo utilizzato solo le operazioni di semigruppo che hanno anche gli elenchi, l'elenco risultante sarà isomorfo rispetto al calcolo originale. E poiché l'operazione originale era associativa, possiamo ugualmente valutare bene gli elementi da davanti a dietro o da davanti a dietro.

Se il tuo algoritmo raggiunge mai un caso base e termina, l'elenco sarà non vuoto. Poiché il case terminale ha restituito qualcosa, quello sarà l'ultimo elemento dell'elenco, quindi avrà almeno un elemento.

Come si applica un'operazione di riduzione binaria a tutti gli elementi di un elenco in ordine? Esatto, una piega. Così si può sostituire [x]per x, ottenere un elenco di elementi da ridurre di <>, e poi o destra-fold o sinistra volte la lista:

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

La versione con foldr1effettivamente esiste nella libreria standard, come sconcatper Semigroupe mconcatper Monoid. Fa una piega a destra pigra sulla lista. Cioè, si espande [Product 2,Product 2,Product 2,Product 2]a 2<>(2<>(2<>(2))).

Questo non è efficace in questo caso perché non puoi fare nulla con i singoli termini finché non li generi tutti. (A un certo punto ho discusso qui su quando usare le pieghe di destra e quando usare le pieghe di sinistra rigorose, ma è andato troppo lontano.)

La versione con foldl1'è una piega a sinistra rigorosamente valutata. Vale a dire, una funzione ricorsiva della coda con un accumulatore rigoroso. Questo viene valutato (((2)<>2)<>2)<>2, calcolato immediatamente e non più tardi quando è necessario. (Almeno, non ci sono ritardi all'interno della piega stessa: l'elenco che viene piegato viene generato qui da un'altra funzione che potrebbe contenere una valutazione pigra.) Quindi, la piega calcola (4<>2)<>2, quindi calcola immediatamente 8<>2, quindi 16. Ecco perché avevamo bisogno che l'operazione fosse associativa: abbiamo appena cambiato il raggruppamento delle parentesi!

La stretta piega a sinistra è l'equivalente di ciò che GCC sta facendo. Il numero più a sinistra nell'esempio precedente è l'accumulatore, in questo caso un prodotto in esecuzione. Ad ogni passaggio, viene moltiplicato per il numero successivo nell'elenco. Un altro modo per esprimerlo è: si scorre sui valori da moltiplicare, mantenendo il prodotto in esecuzione in un accumulatore e su ogni iterazione, si moltiplica l'accumulatore per il valore successivo. Cioè, è un whileciclo sotto mentite spoglie.

A volte può essere reso altrettanto efficiente. Il compilatore potrebbe essere in grado di ottimizzare la struttura dei dati dell'elenco in memoria. In teoria, ha informazioni sufficienti al momento della compilazione per capire che dovrebbe farlo qui: [x]è un singleton, quindi [x]<>xsè lo stesso di cons x xs. Ogni iterazione della funzione potrebbe essere in grado di riutilizzare lo stesso frame dello stack e aggiornare i parametri in atto.

O una piega a destra o una piega a sinistra rigorosa potrebbe essere più appropriata, in un caso particolare, quindi sappi quale vuoi. Ci sono anche alcune cose che solo una piega a destra può fare (come generare output interattivi senza attendere tutto l'input e operare su un elenco infinito). Qui, tuttavia, stiamo riducendo una sequenza di operazioni a un valore semplice, quindi una piega a sinistra rigorosa è ciò che vogliamo.

Quindi, come puoi vedere, è possibile ottimizzare automaticamente il modulo di ricorsione della coda in qualsiasi semigruppo (un esempio dei quali è uno dei soliti tipi numerici sotto moltiplicazione) su una piega destra pigra o una piega sinistra rigida, in una riga di Haskell.

Generalizzare ulteriormente

I due argomenti dell'operazione binaria non devono essere dello stesso tipo, purché il valore iniziale sia dello stesso tipo del risultato. (Ovviamente puoi sempre capovolgere gli argomenti in modo che corrispondano all'ordine del tipo di piega che stai facendo, a sinistra oa destra.) Quindi potresti aggiungere ripetutamente patch a un file per ottenere un file aggiornato o iniziare con un valore iniziale di 1.0, dividi per numeri interi per accumulare un risultato in virgola mobile. O anteporre elementi all'elenco vuoto per ottenere un elenco.

Un altro tipo di generalizzazione è applicare le pieghe non agli elenchi ma ad altre Foldablestrutture di dati. Spesso, un elenco di collegamenti lineari immutabili non è la struttura di dati desiderata per un determinato algoritmo. Un problema che non ho affrontato in precedenza è che è molto più efficiente aggiungere elementi in primo piano rispetto a un elenco piuttosto che in fondo, e quando l'operazione non è commutativa, l'applicazione xa sinistra e a destra dell'operazione non lo sono lo stesso. Quindi avresti bisogno di usare un'altra struttura, come una coppia di liste o albero binario, per rappresentare un algoritmo che potrebbe essere applicato sia xa destra <>che a sinistra.

Si noti inoltre che la proprietà associativa consente di raggruppare le operazioni in altri modi utili, come divide-and-conquer:

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)

O parallelismo automatico, in cui ogni thread riduce una sottorange a un valore che viene quindi combinato con gli altri.


1
Possiamo fare un esperimento per verificare che l'associatività è la chiave per la capacità di GCC di fare questa ottimizzazione: una pow(float x, unsigned n)versione gcc.godbolt.org/z/eqwine ottimizza solo -ffast-math, (che implica -fassociative-math. Virgola mobile Strict è, naturalmente, non è associativa perché diversi provvisori = arrotondamento diverso). L'introduzione di un 1.0f * xnon presente nella macchina astratta C (ma che darà sempre un risultato identico). Quindi le moltiplicazioni n-1 do{res*=x;}while(--n!=1)sono uguali a quelle ricorsive, quindi questa è un'ottimizzazione mancata.
Peter Cordes,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.