Haskell ha un'ottimizzazione ricorsiva della coda?


90

Oggi ho scoperto il comando "time" in unix e ho pensato di usarlo per controllare la differenza nei tempi di esecuzione tra le funzioni ricorsive in coda e quelle ricorsive normali in Haskell.

Ho scritto le seguenti funzioni:

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

Questi sono validi tenendo presente che erano esclusivamente per l'uso con questo progetto, quindi non mi sono preoccupato di controllare zeri o numeri negativi.

Tuttavia, scrivendo un metodo principale per ciascuno, compilarli ed eseguirli con il comando "time", entrambi avevano tempi di esecuzione simili con la normale funzione ricorsiva che usciva da quella ricorsiva di coda. Questo è contrario a quello che avevo sentito riguardo all'ottimizzazione ricorsiva della coda in lisp. Qual è il motivo?


8
Credo che il TCO sia un'ottimizzazione per salvare un po 'di stack di chiamate, non implica che risparmierai un po' di tempo della CPU. Correggimi se sbaglio.
Jerome

3
Non l'ho testato con lisp, ma il tutorial che ho letto implicava che la configurazione dello stack ha comportato di per sé più costi del processore, mentre la soluzione ricorsiva di coda da compilata a iterativa non ha impiegato energia (tempo) per farlo era più efficiente.
Haskell Rascal

1
@ Jerome, beh, dipende da molte cose, ma in genere entrano in gioco anche le cache, quindi il TCO di solito produrrà anche un programma più veloce ..
Kristopher Micinski

Qual è il motivo? In una parola: pigrizia.
Dan Burton

È interessante notare che il tuo facè più o meno il modo in cui ghc calcola product [n,n-1..1]utilizzando una funzione ausiliaria prod, ma ovviamente product [1..n]sarebbe più semplice. Posso solo presumere che non lo abbiano reso rigoroso nel suo secondo argomento sulla base del fatto che questo è il genere di cose che ghc è molto fiducioso di poter compilare fino a un semplice accumulatore.
AndrewC

Risposte:


171

Haskell utilizza la valutazione lazy per implementare la ricorsione, quindi considera qualsiasi cosa come una promessa per fornire un valore quando necessario (questo è chiamato thunk). I thunk vengono ridotti solo quanto necessario per procedere, non di più. Questo assomiglia al modo in cui semplifichi matematicamente un'espressione, quindi è utile pensarla in questo modo. Il fatto che l'ordine di valutazione non sia specificato dal codice consente al compilatore di eseguire molte ottimizzazioni ancora più intelligenti rispetto alla semplice eliminazione della chiamata di coda a cui sei abituato. Compila con -O2se vuoi l'ottimizzazione!

Vediamo come valutiamo facSlow 5come caso di studio:

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

Quindi, proprio come ti preoccupavi, abbiamo un accumulo di numeri prima che avvenga qualsiasi calcolo, ma a differenza di te preoccupato, non ci sono stack di facSlowchiamate di funzione in attesa di essere terminate - ogni riduzione viene applicata e scompare, lasciando uno stack frame nel suo wake (questo perché (*)è rigoroso e quindi attiva la valutazione del suo secondo argomento).

Le funzioni ricorsive di Haskell non vengono valutate in modo molto ricorsivo! L'unica pila di chiamate in giro sono le moltiplicazioni stesse. Se (*)è visto come un costruttore di dati rigoroso, questo è ciò che è noto come ricorsione protetta (anche se di solito viene indicato come tale con i costruttori di dati non rigidi, dove ciò che rimane sulla sua scia sono i costruttori di dati, se forzato da un ulteriore accesso).

Ora diamo un'occhiata alla coda ricorsiva 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

Quindi puoi vedere come la ricorsione della coda da sola non ti abbia salvato tempo o spazio. Non solo richiede più passaggi complessivi facSlow 5, ma crea anche un thunk annidato (mostrato qui come {...}) - che richiede uno spazio aggiuntivo per esso - che descrive il calcolo futuro, le moltiplicazioni annidate da eseguire.

Questo thunk viene poi dipana attraversando essa verso il basso, ricreando il calcolo in pila. C'è anche il pericolo di causare un overflow dello stack con calcoli molto lunghi, per entrambe le versioni.

Se vogliamo ottimizzarlo manualmente, tutto ciò che dobbiamo fare è renderlo rigoroso. È possibile utilizzare l'operatore dell'applicazione rigorosa $!per definire

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

Questo costringe facS'ad essere rigoroso nel suo secondo argomento. (È già rigoroso nel suo primo argomento perché deve essere valutato per decidere quale definizione facS'applicare.)

A volte il rigore può aiutare enormemente, a volte è un grosso errore perché la pigrizia è più efficiente. Ecco una buona idea:

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

Che è quello che volevi ottenere, credo.

Sommario

  • Se vuoi ottimizzare il tuo codice, il primo passo è compilare con -O2
  • La ricorsione della coda è utile solo quando non c'è accumulo di thunk e l'aggiunta di rigidità di solito aiuta a prevenirlo, se e dove appropriato. Questo accade quando stai costruendo un risultato che è necessario in seguito tutto in una volta.
  • A volte la ricorsione della coda è un cattivo piano e la ricorsione sorvegliata è più adatta, cioè quando il risultato che stai costruendo sarà necessario poco a poco, in porzioni. Vedi questa domanda su foldre foldlper esempio, e mettili alla prova l'uno contro l'altro.

Prova questi due:

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!!!"

foldl1è ricorsivo di coda, mentre foldr1esegue la ricorsione protetta in modo che il primo elemento venga immediatamente presentato per ulteriori elaborazioni / accessi. (La prima "parentesi" a sinistra immediatamente, (...((s+s)+s)+...)+sforzando la sua lista di input completamente alla sua fine e costruendo un grande thunk di calcoli futuri molto prima che i suoi risultati completi siano necessari; la seconda parentesi a destra gradualmente s+(s+(...+(s+s)...)),, consumando l'input list un po 'alla volta, così il tutto è in grado di operare in spazio costante, con ottimizzazioni).

Potrebbe essere necessario regolare il numero di zeri a seconda dell'hardware in uso.


1
@ WillNess Eccellente, grazie. non c'è bisogno di ritrarre. Penso che ora sia una risposta migliore per i posteri.
AndrewC

4
È fantastico, ma posso suggerire un cenno all'analisi del rigore ? Penso che quasi certamente farà il lavoro per il fattoriale ricorsivo di coda in qualsiasi versione ragionevolmente recente di GHC.
dfeuer

16

Va detto che la facfunzione non è un buon candidato per la ricorsione protetta. La ricorsione della coda è la strada da percorrere qui. A causa della pigrizia, non si ottiene l'effetto del TCO nella fac'funzione perché gli argomenti dell'accumulatore continuano a creare thunk di grandi dimensioni che quando valutati richiederanno uno stack enorme. Per evitare ciò e ottenere l'effetto desiderato del TCO, è necessario rendere rigorosi questi argomenti dell'accumulatore.

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

Se compili usando -O2(o solo -O) GHC probabilmente lo farà da solo nella fase di analisi di rigore .


4
Penso che sia più chiaro con $!che con BangPatterns, ma questa è una buona risposta. Soprattutto la menzione dell'analisi di rigore.
singpolyma

7

Dovresti controllare l'articolo wiki su ricorsione della coda in Haskell . In particolare, a causa della valutazione dell'espressione, il tipo di ricorsione desiderato è la ricorsione protetta . Se si elaborano i dettagli di ciò che sta succedendo sotto il cofano (nella macchina astratta per Haskell) si ottengono le stesse cose della ricorsione della coda nei linguaggi rigorosi. Insieme a questo, hai una sintassi uniforme per le funzioni pigre (la ricorsione della coda ti legherà a una valutazione rigorosa, mentre la ricorsione protetta funziona in modo più naturale).

(E nell'apprendimento di Haskell, anche il resto di quelle pagine wiki sono fantastiche!)


0

Se ricordo bene, GHC ottimizza automaticamente le funzioni ricorsive semplici in quelle ottimizzate ricorsive di coda.

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.