Perché i loop sono più veloci della ricorsione?


17

In pratica, capisco che qualsiasi ricorsione può essere scritta come un ciclo (e viceversa (?)) E se misuriamo con i computer reali scopriamo che i cicli sono più veloci della ricorsione per lo stesso problema. Ma c'è qualche teoria che fa questa differenza o è principalmente imprecisa?


9
Gli sguardi sono solo più veloci della ricorsione in lingue che li implementano male. In una lingua con una corretta ricorsione della coda, i programmi ricorsivi possono essere tradotti in loop dietro le quinte, nel qual caso non ci sarebbe differenza perché sono identici.
jmite,

3
Sì, e se usi un linguaggio che lo supporta, puoi usare la ricorsione (coda) senza effetti negativi sulle prestazioni.
jmite

1
@jmite, la ricorsione della coda che può essere effettivamente ottimizzata in un ciclo è estremamente rara, molto più rara di quanto si pensi. Soprattutto in linguaggi che hanno gestito tipi come variabili conteggio dei riferimenti.
Johan - ripristina Monica

1
Dato che hai incluso il tag complessità temporale, ritengo che dovrei aggiungere che un algoritmo con un ciclo ha la stessa complessità temporale di un algoritmo con ricorsione, ma con quest'ultimo il tempo impiegato sarà maggiore di un fattore costante, a seconda del quantità di spese generali per la ricorsione.
Lieuwe Vinkhuijzen,

2
Ehi, dal momento che hai aggiunto la grazia con molte buone risposte quasi esaurendo tutte le possibilità, c'è qualcosa di più che ti serve o senti che qualcosa dovrebbe essere chiarito? Non ho molto da aggiungere, posso modificare qualche risposta o lasciare un commento, quindi questa è una domanda generale (non personale).
Evil

Risposte:


17

La ragione per cui i loop sono più veloci della ricorsione è facile.
Un ciclo si presenta così nell'assemblaggio.

mov loopcounter,i
dowork:/do work
dec loopcounter
jmp_if_not_zero dowork

Un singolo salto condizionale e un po 'di contabilità per il contatore loop.

La ricorsione (quando non è o non può essere ottimizzata dal compilatore) è simile alla seguente:

start_subroutine:
pop parameter1
pop parameter2
dowork://dowork
test something
jmp_if_true done
push parameter1
push parameter2
call start_subroutine
done:ret

È molto più complesso e ottieni almeno 3 salti (1 test per vedere se sono stati fatti, una chiamata e un ritorno).
Anche in ricorsione i parametri devono essere impostati e recuperati.
Nessuna di queste cose è necessaria in un ciclo perché tutti i parametri sono già impostati.

Teoricamente i parametri potrebbero rimanere al loro posto anche con la ricorsione, ma nessun compilatore che conosco effettivamente va così lontano nella loro ottimizzazione.

Differenze tra una chiamata e un jmp
Una coppia call-return non è molto più costosa del jmp. La coppia richiede 2 cicli e il jmp richiede 1; appena percettibile.
Nel chiamare le convenzioni che supportano i parametri di registro, l'overhead per i parametri è minimo, ma anche i parametri dello stack sono economici purché i buffer della CPU non trabocchino .
È il sovraccarico della configurazione della chiamata dettato dalla convenzione di chiamata e dalla gestione dei parametri in uso che rallenta la ricorsione.
Questo dipende molto dall'implementazione.

Esempio di una cattiva gestione della ricorsione Ad esempio, se viene passato un parametro che viene contato come riferimento (ad es. Un parametro di tipo gestito non const) aggiungerà 100 cicli eseguendo una regolazione bloccata del conteggio dei riferimenti, uccidendo totalmente le prestazioni contro un loop.
Nelle lingue ottimizzate per la ricorsione questo cattivo comportamento non si verifica.

Ottimizzazione della CPU
L'altro motivo per cui la ricorsione è più lenta è che funziona contro i meccanismi di ottimizzazione nelle CPU.
I resi possono essere previsti correttamente solo se non ce ne sono molti di seguito. La CPU ha un buffer dello stack di ritorno con una manciata di voci. Una volta esauriti, ogni ritorno aggiuntivo sarà erroneamente causato causando enormi ritardi.
Su qualsiasi CPU che utilizza una ricorrenza basata sul buffer di ritorno dello stack che supera la dimensione del buffer, è meglio evitare.

Informazioni su esempi di codice banali usando la ricorsione
Se usi un esempio banale di ricorsione come la generazione di numeri di Fibonacci, allora questi effetti non si verificano, perché qualsiasi compilatore che è "a conoscenza" della ricorsione lo trasformerà in un ciclo, proprio come qualsiasi programmatore degno del suo sale voluto.
Se esegui questi esempi banali in un ambiente che non si ottimizza correttamente rispetto allo stack di chiamate (inutilmente) crescerà fuori dai limiti.

Informazioni sulla ricorsione della coda
Si noti che a volte il compilatore ottimizza la ricorsione della coda modificandola in un ciclo. È meglio fare affidamento su questo comportamento solo in lingue che hanno una buona esperienza in questo senso.
Molte lingue inseriscono il codice di pulizia nascosto prima del ritorno finale impedendo l'ottimizzazione della ricorsione della coda.

Confusione tra ricorsione vera e pseudo
Se l'ambiente di programmazione trasforma il codice sorgente ricorsivo in un ciclo, probabilmente non è una vera ricorsione che viene eseguita.
La vera ricorsione richiede un deposito di pangrattato, in modo che la routine ricorsiva possa risalire ai suoi passi dopo la sua uscita.
È la gestione di questa traccia che rende la ricorsione più lenta rispetto all'utilizzo di un loop. Questo effetto è amplificato dalle attuali implementazioni della CPU come indicato sopra.

Effetto dell'ambiente di programmazione
Se il tuo linguaggio è ottimizzato per l'ottimizzazione della ricorsione, allora vai avanti e usa la ricorsione in ogni occasione. Nella maggior parte dei casi la lingua trasformerà la tua ricorsione in una sorta di ciclo.
In quei casi in cui non è possibile, anche il programmatore sarebbe difficile. Se il tuo linguaggio di programmazione non è ottimizzato per la ricorsione, allora dovrebbe essere evitato a meno che il dominio non sia adatto alla ricorsione.
Sfortunatamente molte lingue non gestiscono bene la ricorsione.

Uso improprio della ricorsione
Non è necessario calcolare la sequenza di Fibonacci utilizzando la ricorsione, in realtà è un esempio patologico.
La ricorsione è utilizzata al meglio nelle lingue che la supportano esplicitamente o nei domini in cui la ricorsione brilla, come la gestione dei dati memorizzati in un albero.

Capisco che qualsiasi ricorsione può essere scritta come un ciclo

Sì, se sei disposto a mettere il carrello davanti al cavallo.
Tutte le istanze di ricorsione possono essere scritte come un ciclo, alcune di queste istanze richiedono l'uso di uno stack esplicito come l'archiviazione.
Se devi far rotolare il tuo stack solo per trasformare il codice ricorsivo in un ciclo, potresti anche usare la semplice ricorsione.
A meno che ovviamente non abbiate esigenze particolari come l'uso degli enumeratori in una struttura ad albero e non abbiate un supporto linguistico adeguato.


16

Queste altre risposte sono in qualche modo fuorvianti. Concordo sul fatto che dichiarano dettagli di attuazione che possono spiegare questa disparità, ma sopravvalutano il caso. Come giustamente suggerito da jmite, sono orientate all'attuazione verso rotti implementazioni di chiamate di funzione / ricorsione. Molte lingue implementano i loop attraverso la ricorsione, quindi i loop chiaramente non saranno più veloci in quelle lingue. In teoria la ricorsione non è in alcun modo meno efficace del looping (quando entrambi sono applicabili). Permettetemi di citare l'abstract sul documento di Guy Steele del 1977 Debunking il mito "Expensive Procedure Call" o, Implementazioni di procedura considerate dannose o, Lambda: il GOTO definitivo

Il folklore afferma che le dichiarazioni GOTO sono "economiche", mentre le chiamate di procedura sono "costose". Questo mito è in gran parte il risultato di implementazioni linguistiche mal progettate. La crescita storica di questo mito è considerata. Vengono discusse sia idee teoriche sia un'implementazione esistente che sfatano questo mito. È dimostrato che l'uso senza restrizioni delle chiamate di procedura consente una grande libertà stilistica. In particolare, qualsiasi diagramma di flusso può essere scritto come un programma "strutturato" senza introdurre variabili aggiuntive. La difficoltà con l'istruzione GOTO e la chiamata alla procedura è caratterizzata da un conflitto tra concetti di programmazione astratti e costrutti di linguaggio concreti.

Il "conflitto tra concetti di programmazione astratti e costrutti di linguaggio concreti" può essere visto dal fatto che la maggior parte dei modelli teorici, ad esempio, del calcolo lambda non tipizzato , non hanno una pila . Ovviamente, questo conflitto non è necessario come illustra il documento sopra e come è dimostrato anche da lingue che non hanno meccanismi di iterazione diversi dalla ricorsione come Haskell.

fixfix f x = f (fix f) x(λX.M)NM[N/X][N/X]XMN

Ora per un esempio. Definisci factcome

fact = fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 1

Ecco la valutazione di fact 3dove, per compattezza, userò gcome sinonimo di fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)), cioè fact = g 1. Questo non influisce sul mio argomento.

fact 3 
~> g 1 3
~> fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 1 3 
~> (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) g 1 3
~> (λa.λn.if n == 0 then a else g (a*n) (n-1)) 1 3
~> (λn.if n == 0 then 1 else g (1*n) (n-1)) 3
~> if 3 == 0 then 1 else g (1*3) (3-1)
~> g (1*3) (3-1)
~> g 3 2
~> fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 3 2
~> (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) g 3 2
~> (λa.λn.if n == 0 then a else g (a*n) (n-1)) 3 2
~> (λn.if n == 0 then 3 else g (3*n) (n-1)) 2
~> if 2 == 0 then 3 else g (3*2) (2-1)
~> g (3*2) (2-1)
~> g 6 1
~> fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 6 1
~> (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) g 6 1
~> (λa.λn.if n == 0 then a else g (a*n) (n-1)) 6 1
~> (λn.if n == 0 then 6 else g (6*n) (n-1)) 1
~> if 1 == 0 then 6 else g (6*1) (1-1)
~> g (6*1) (1-1)
~> g 6 0
~> fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 6 0
~> (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) g 6 0
~> (λa.λn.if n == 0 then a else g (a*n) (n-1)) 6 0
~> (λn.if n == 0 then 6 else g (6*n) (n-1)) 0
~> if 0 == 0 then 6 else g (6*0) (0-1)
~> 6

Puoi vedere dalla forma senza nemmeno guardare i dettagli che non c'è crescita e che ogni iterazione necessita della stessa quantità di spazio. (Tecnicamente, il risultato numerico cresce, il che è inevitabile e altrettanto vero per un whileciclo.) Ti sfido a sottolineare lo "stack" senza limiti di crescita qui.

Sembra che la semantica archetipica del calcolo lambda faccia già quello che viene comunemente chiamato "ottimizzazione della coda". Naturalmente, qui non sta avvenendo alcuna "ottimizzazione". Non ci sono regole speciali qui per le chiamate "di coda" rispetto alle chiamate "normali". Per questo motivo, è difficile dare una caratterizzazione "astratta" di ciò che sta facendo l'ottimizzazione delle chiamate di coda, come in molte caratterizzazioni astratte della semantica delle chiamate di funzione, non c'è nulla da fare per "l'ottimizzazione" delle chiamate di coda!

Che la definizione analoga di fact"overflow dello stack" in molte lingue, è un fallimento da parte di tali lingue nell'implementare correttamente la semantica della chiamata di funzione. (Alcune lingue hanno una scusa.) La situazione è all'incirca analoga all'avere un'implementazione del linguaggio che implementa array con elenchi collegati. L'indicizzazione in tali "matrici" sarebbe quindi un'operazione O (n) che non soddisfa le aspettative delle matrici. Se avessi realizzato un'implementazione separata del linguaggio, che utilizzava array reali anziché elenchi collegati, non diresti che ho implementato "ottimizzazione dell'accesso agli array", diresti che ho risolto un'implementazione non corretta degli array.

Quindi, rispondendo alla risposta di Veedrac. Le pile non sono "fondamentali" per la ricorsione . Nella misura in cui si verifica un comportamento "simile a stack" nel corso della valutazione, ciò può avvenire solo nei casi in cui i loop (senza una struttura di dati ausiliaria) non sarebbero applicabili in primo luogo! Per dirla in altro modo, posso implementare loop con ricorsione con esattamente le stesse caratteristiche prestazionali. In effetti, Scheme e SML contengono entrambi costrutti loop, ma entrambi definiscono quelli in termini di ricorsione (e, almeno in Scheme, dosono spesso implementati come una macro che si espande in chiamate ricorsive). Analogamente, per la risposta di Johan, nulla dice un il compilatore deve emettere l'assemblaggio che Johan ha descritto per la ricorsione. Infatti,esattamente lo stesso assemblaggio se si utilizzano loop o ricorsioni. L'unica volta in cui il compilatore sarebbe (in qualche modo) obbligato a emettere assembly come quello che Johan descrive è quando stai facendo qualcosa che non è comunque esprimibile da un loop. Come indicato nel documento di Steele e dimostrato dalla pratica di linguaggi come Haskell, Scheme e SML, non è "estremamente raro" che le chiamate di coda possano essere "ottimizzate", possono sempreessere "ottimizzato". Se un uso particolare della ricorsione verrà eseguito in uno spazio costante dipende da come viene scritto, ma le restrizioni che è necessario applicare per renderlo possibile sono le restrizioni necessarie per adattare il problema alla forma di un ciclo. (In realtà, sono meno rigorosi. Ci sono problemi, come la codifica delle macchine a stati, che sono gestiti in modo più pulito ed efficiente tramite chiamate di coda rispetto ai loop che richiederebbero variabili ausiliarie.) Ancora una volta, l'unica volta che la ricorsione richiede di fare più lavoro è quando il tuo codice non è un loop comunque.

La mia ipotesi è che Johan si riferisca ai compilatori C che hanno restrizioni arbitrarie su quando eseguirà "l'ottimizzazione" delle chiamate di coda. Anche presumibilmente Johan si riferisce a linguaggi come C ++ e Rust quando parla di "linguaggi con tipi gestiti". Il linguaggio RAII del C ++ e presente anche in Rust rende le cose che sembrano superficialmente chiamate di coda, non chiamate di coda (perché i "distruttori" devono ancora essere chiamati). Ci sono state proposte per utilizzare una sintassi diversa per optare per una semantica leggermente diversa che consentirebbe la ricorsione della coda (cioè prima di chiamare i distruttoril'ultima chiamata finale e ovviamente non consente l'accesso agli oggetti "distrutti"). (La garbage collection non presenta questo problema, e tutti gli Haskell, SML e Scheme sono linguaggi di garbage collection.) In una vena abbastanza diversa, alcune lingue, come Smalltalk, espongono lo "stack" come oggetto di prima classe, in questi casi lo "stack" non è più un dettaglio di implementazione, sebbene ciò non precluda la presenza di tipi separati di chiamate con semantica diversa. (Java dice che non può a causa del modo in cui gestisce alcuni aspetti della sicurezza, ma questo è in realtà falso .)

In pratica, la prevalenza di implementazioni errate di chiamate di funzioni deriva da tre fattori principali. Innanzitutto, molte lingue ereditano l'implementazione non corretta dal loro linguaggio di implementazione (di solito C). In secondo luogo, la gestione delle risorse deterministiche è piacevole e rende il problema più complicato, sebbene solo una manciata di lingue lo offra. In terzo luogo, e, nella mia esperienza, il motivo per cui la maggior parte delle persone si preoccupa, è che vogliono tracce dello stack quando si verificano errori per scopi di debug. Solo la seconda ragione è quella che può essere potenzialmente teoricamente motivata.


Ho usato "fondamentale" per riferirmi alla ragione più elementare che l'affermazione è vera, non sul fatto che debba essere logicamente in questo modo (cosa che chiaramente non lo è, dato che i due programmi sono dimostrabilmente identici). Ma non sono d'accordo con il tuo commento nel suo insieme. L'uso del calcolo lambda non rimuove la pila quanto oscura.
Veedrac,

La tua affermazione "L'unica volta in cui il compilatore sarebbe (in qualche modo) obbligato a emettere assembly come quello che Johan descrive è quando stai facendo qualcosa che non è comunque esprimibile da un loop." è anche abbastanza strano; un compilatore è (normalmente) in grado di produrre qualsiasi codice che produce lo stesso output, quindi il tuo commento è fondamentalmente una tautologia. Ma in pratica i compilatori producono codice diverso per diversi programmi equivalenti e la domanda era perché.
Veedrac,

O(1)

Dare un'analogia, rispondere a una domanda sul perché l'aggiunta di stringhe immutabili nei loop richiede tempo quadratico con "non deve essere" sarebbe del tutto ragionevole, ma continuare affermando che l'implementazione così interrotta non lo sarebbe.
Veedrac,

Risposta molto interessante Anche se suona un po 'come un rant :-). È stato votato perché ho imparato qualcosa di nuovo.
Johan - ripristina Monica

2

Fondamentalmente la differenza è che la ricorsione include uno stack, una struttura di dati ausiliari che probabilmente non si desidera, mentre i loop non lo fanno automaticamente. Solo in rari casi un tipico compilatore è in grado di dedurre che in realtà non è necessario lo stack.

Se si confrontano invece i cicli che funzionano manualmente su uno stack allocato (ad es. Tramite un puntatore alla memoria dell'heap), normalmente non li si troverà più veloci o addirittura più lenti dell'uso dello stack hardware.

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.