Quando non c'è TCO, quando preoccuparsi di far saltare in pila?


14

Ogni volta che si discute di un nuovo linguaggio di programmazione mirato alla JVM, ci sono inevitabilmente persone che dicono cose come:

"La JVM non supporta l'ottimizzazione delle chiamate in coda, quindi prevedo un sacco di pile esplosive"

Ci sono migliaia di variazioni su quel tema.

Ora so che alcune lingue, come Clojure per esempio, hanno un costrutto ricorrente che puoi usare.

Quello che non capisco è: quanto è grave la mancanza di ottimizzazione delle chiamate in coda? Quando dovrei preoccuparmene?

La mia principale fonte di confusione deriva probabilmente dal fatto che Java è uno dei linguaggi di maggior successo di sempre e parecchi dei linguaggi JVM sembrano andare abbastanza bene. Come è possibile se la mancanza di TCO è davvero di qualsiasi preoccupazione?


4
se hai una ricorsione abbastanza profonda da far saltare lo stack senza TCO, allora avrai un problema anche con TCO
maniaco del cricchetto

18
@ratchet_freak È una sciocchezza. Lo schema non ha nemmeno loop, ma poiché le specifiche impongono il supporto TCO, l'iterazione ricorsiva su un ampio set di dati non è più costosa di un ciclo imperativo (con il bonus che il costrutto Scheme restituisce un valore).
itsbruce

6
@ratchetfreak Il TCO è un meccanismo per rendere le funzioni ricorsive scritte in un certo modo (cioè ricorsivamente in coda) per non essere in grado di far saltare lo stack anche se lo volessero. La tua affermazione ha senso solo per la ricorsione che non è scritta in modo ricorsivo di coda, nel qual caso hai ragione e TCO non ti aiuterà.
Evicatos,

2
L'ultima volta che ho guardato, l'80x86 non fa neanche l'ottimizzazione (nativa) delle chiamate in coda. Ma ciò non ha impedito agli sviluppatori di lingue di eseguire il porting delle lingue che lo utilizzano. Il compilatore identifica quando può usare un salto contro un jsr e tutti sono felici. Puoi fare la stessa cosa su una JVM.
kdgregory,

3
@kdgregory: Ma l'x86 ha GOTO, la JVM no. E x86 non viene utilizzato come piattaforma di interoperabilità. La JVM non ha GOTOe uno dei motivi principali per la scelta della piattaforma Java è l'interoperabilità. Se vuoi implementare il TCO sulla JVM, devi fare qualcosa nello stack. Gestisci tu stesso (ovvero non utilizzare affatto lo stack di chiamate JVM), usa i trampolini, usa le eccezioni come GOTO, qualcosa del genere. In tutti questi casi, si diventa incompatibili con lo stack di chiamate JVM. È impossibile essere compatibili con lo stack con Java, avere un TCO e prestazioni elevate. Devi sacrificare uno di quei tre.
Jörg W Mittag,

Risposte:


16

Consideriamo questo, diciamo che ci siamo sbarazzati di tutti i loop in Java (gli autori del compilatore sono in sciopero o qualcosa del genere). Ora vogliamo scrivere fattoriale, quindi potremmo correggere qualcosa del genere

int factorial(int i){ return factorial(i, 1);}
int factorial(int i, int accum){
  if(i == 0) return accum;
  return factorial(i-1, accum * i);
}

Ora ci sentiamo abbastanza intelligenti, siamo riusciti a scrivere il nostro fattoriale anche senza loop! Ma quando testiamo, notiamo che con qualsiasi numero di dimensioni ragionevoli, riceviamo errori di stackoverflow poiché non c'è TCO.

In Java reale questo non è un problema. Se mai avremo un algoritmo ricorsivo di coda, possiamo trasformarlo in un ciclo e stare bene. Tuttavia, che dire delle lingue senza loop? Allora sei solo incantato. Ecco perché il clojure ha questa recurforma, senza di essa, non è nemmeno completamente completo (nessun modo per fare loop infiniti).

La classe di linguaggi funzionali che hanno come target JVM, Frege, Kawa (Scheme), Clojure stanno sempre cercando di affrontare la mancanza di chiamate di coda, perché in queste lingue, TC è il modo idiomatico di fare loop! Se tradotto in Scheme, quel fattoriale sopra sarebbe un buon fattoriale. Sarebbe terribilmente scomodo se il looping di 5000 volte causasse l'arresto anomalo del programma. Questo può essere risolto, però, con recurmoduli speciali, annotazioni che suggeriscono di ottimizzare le auto chiamate, il trampolino, qualunque cosa. Ma tutti costringono sia a prestazioni che a risultati inutili sul programmatore.

Ora neanche Java è libero, dal momento che c'è molto di più del TCO oltre alla semplice ricorsione, che dire delle funzioni reciprocamente ricorsive? Non possono essere direttamente tradotti in loop, ma non sono ancora ottimizzati dalla JVM. Questo rende straordinariamente spiacevole provare a scrivere algoritmi usando la ricorsione reciproca usando Java poiché se vuoi prestazioni / range decenti devi fare magie oscure per farlo rientrare nei loop.

Quindi, in sintesi, questo non è un grosso problema per molti casi. La maggior parte delle chiamate di coda procede solo con uno stackframe profondo, con cose come

return foo(bar, baz); // foo is just a simple method

o sono ricorsione. Tuttavia, per la classe di TC che non rientra in questo, ogni linguaggio JVM avverte dolore.

Tuttavia, c'è una ragione decente per cui non abbiamo ancora il TCO. La JVM ci fornisce tracce dello stack. Con TCO eliminiamo sistematicamente gli stackframe che sappiamo essere "condannati", ma la JVM potrebbe effettivamente desiderarli in seguito per uno stacktrace! Supponiamo che implementiamo un FSM come questo, in cui ogni stato chiama in coda il prossimo. Cancelleremmo tutti i record degli stati precedenti in modo che un traceback ci mostrasse quale stato, ma non nulla di come ci siamo arrivati.

Inoltre, e più urgentemente, gran parte della verifica del bytecode è basata sullo stack, eliminando ciò che ci consente di verificare che il bytecode non sia una prospettiva piacevole. Tra questo e il fatto che Java abbia dei loop, il TCO sembra un po 'più problematico di quanto valga la pena per gli ingegneri JVM.


2
Il problema più grande è il verificatore del codice byte, che è completamente basato sull'ispezione dello stack. Questo è un bug importante nella specifica JVM. 25 anni fa, quando è stata progettata la JVM, le persone hanno già detto che sarebbe meglio avere il linguaggio del codice byte JVM per essere sicuro in primo luogo piuttosto che avere quella lingua non sicura e quindi fare affidamento sulla verifica del codice byte dopo il fatto. Tuttavia, Matthias Felleisen (una delle figure di spicco della comunità Scheme) ha scritto un documento che dimostra come le chiamate di coda possono essere aggiunte alla JVM preservando il verificatore del codice byte.
Jörg W Mittag,

2
È interessante notare che il J9 JVM da parte di IBM non effettua il TCO.
Jörg W Mittag,

1
@jozefg È interessante notare che a nessuno importa delle voci dello stacktrace per i loop, quindi l'argomento stacktrace non contiene acqua, almeno per le funzioni ricorsive della coda.
Ingo

2
@MasonWheeler Questo è esattamente il mio punto: lo stacktrace non ti dice in quale iterazione è successo. Puoi vederlo solo indirettamente, ispezionando le variabili del ciclo, ecc. Quindi, perché dovresti voler inserire diverse voci di traccia dello stack hundert di una funzione ricorsiva della coda? Solo l'ultimo è interessante! E, come con i loop, puoi determinare quale ricorsione è stata ispezionando le variabili locali, i valori degli argomenti, ecc.
Ingo

3
@Ingo: se una funzione ricorre solo a se stessa, la traccia dello stack potrebbe non mostrare molto. Se, tuttavia, un gruppo di funzioni è reciprocamente ricorsivo, a volte una traccia dello stack può mostrare molto.
supercat

7

L'ottimizzazione delle chiamate di coda è principalmente importante a causa della ricorsione della coda. Tuttavia, c'è un argomento per cui è effettivamente positivo che la JVM non ottimizzi le chiamate di coda: poiché il TCO riutilizza una parte dello stack, una traccia dello stack da un'eccezione sarà incompleta, rendendo così il debug un po 'più difficile.

Esistono modi per aggirare i limiti della JVM:

  1. Il compilatore può facilmente ottimizzare la ricorsione della coda in un loop.
  2. Se il programma è in stile di passaggio di continuazione, è banale usare il "trampolino". Qui, una funzione non restituisce il risultato finale, ma una continuazione che viene quindi eseguita all'esterno. Questa tecnica consente a un autore di compilatori di modellare un flusso di controllo arbitrariamente complesso.

Potrebbe essere necessario un esempio più ampio. Prendi in considerazione una lingua con chiusure (ad es. JavaScript o simili). Possiamo scrivere il fattoriale come

def fac(n, acc = 1) = if (n <= 1) acc else n * fac(n-1, acc*n)

print fac(x)

Ora possiamo invece restituire una richiamata:

def fac(n, acc = 1) =
  if (n <= 1) acc
  else        (() => fac(n-1, acc*n))  // this isn't full CPS, but you get the idea…

var continuation = (() => fac(x))
while (continuation instanceof function) {
  continuation = continuation()
}
var result = continuation
print result

Ora funziona in uno spazio di stack costante, che è un po 'sciocco perché è comunque ricorsivo alla coda. Tuttavia, questa tecnica è in grado di appiattire tutte le chiamate di coda nello spazio di stack costante. E se il programma è in CPS, ciò significa che il callstack è complessivamente costante (in CPS, ogni chiamata è una coda).

Uno svantaggio principale di questa tecnica è che è molto più difficile eseguire il debug, un po 'più difficile da implementare e meno performante - vedi tutte le chiusure e le indirette che sto usando.

Per questi motivi sarebbe molto preferibile che la VM implementasse una call tail - i linguaggi come Java che hanno buone ragioni per non supportare le tail tail non dovrebbero usarla.


1
"Poiché il TCO riutilizza una parte dello stack, una traccia dello stack da un'eccezione sarà incompleta", sì, ma anche una traccia stack all'interno di un ciclo è incompleta - non registra la frequenza con cui il ciclo è stato eseguito. - Ahimè, anche se la JVM supporterebbe chiamate di coda adeguate, si potrebbe ancora rinunciare, durante il debug, per esempio. Quindi, per la produzione, consentire a TCO di assicurarsi che il codice venga eseguito con 100.000 o 100.000.000 di chiamate di coda.
Ingo

1
@Ingo No. (1) Quando i loop non sono implementati come ricorsione, non vi è alcuna logica per mostrarli nello stack (call tail ≠ jump ≠ call). (2) Il TCO è più generale dell'ottimizzazione della ricorsione della coda. La mia risposta usa la ricorsione come esempio . (3) Se stai programmando in uno stile che si basa sul TCO, disattivare questa ottimizzazione non è un'opzione: il TCO completo o le tracce dello stack completo sono una funzionalità del linguaggio o non lo sono. Ad esempio, Scheme riesce a bilanciare gli svantaggi del TCO con un sistema di eccezioni più avanzato.
am

1
(1) pienamente d'accordo. Ma con lo stesso ragionamento, non esiste una logica per mantenere centinaia e migliaia di voci di traccia stack che tutti indicano return foo(....);nel metodo foo(2) pienamente d'accordo, ovviamente. Tuttavia, accettiamo la traccia incompleta da loop, assegnazioni (!), Sequenze di istruzioni. Ad esempio, se trovi un valore imprevisto in una variabile, sicuramente vorrai sapere come è arrivato lì. Ma in questo caso non ti lamenti delle tracce mancanti. Perché è in qualche modo inciso nel nostro cervello che a) succede solo su chiamate b) succede su tutte le chiamate. Entrambi non hanno senso, IMHO.
Ingo

(3) Non sono d'accordo. Non riesco a vedere alcun motivo per cui dovrebbe essere impossibile eseguire il debug del mio codice con un problema di dimensioni N, per alcuni N abbastanza piccoli da cavarsela con lo stack normale. E poi, per attivare l'interruttore e attivare il TCO, eliminando efficacemente il vincolo sulla dimensione del probem.
Ingo

@Ingo “Non sono d'accordo. Non riesco a vedere alcun motivo per cui dovrebbe essere impossibile eseguire il debug del mio codice con un problema di dimensioni N, per alcuni N abbastanza piccoli da cavarsela con lo stack normale. ”Se TCO / TCE è per una trasformazione CPS, quindi trasformarlo off sovraccaricherà lo stack e bloccherà il programma, quindi non sarebbe possibile eseguire il debug. Google ha rifiutato di implementare il TCO in V8 JS, a causa di questo problema che si è verificato per inciso . Vorrebbero una sintassi speciale in modo che il programmatore possa dichiarare di voler davvero TCO e la perdita della traccia dello stack. Qualcuno sa se anche le eccezioni sono rovinate dal TCO?
Shelby Moore III,

6

Una parte significativa delle chiamate in un programma sono chiamate di coda. Ogni subroutine ha un'ultima chiamata, quindi ogni subroutine ha almeno una chiamata di coda. Le chiamate di coda hanno le caratteristiche prestazionali GOTOma la sicurezza di una chiamata di subroutine.

Avere chiamate di coda adeguate consente di scrivere programmi che altrimenti non si potrebbero scrivere. Prendi, ad esempio, una macchina a stati. Una macchina a stati può essere implementata in modo molto diretto facendo in modo che ogni stato sia una subroutine e ogni transizione di stato sia una chiamata a subroutine. In tal caso, passi da uno stato all'altro, effettuando una chiamata dopo l'altra dopo l'altra e in realtà non ritorni mai più! Senza le giuste chiamate di coda, faresti immediatamente saltare la pila.

Senza PTC, devi usare GOTOo trampolini o eccezioni come flusso di controllo o qualcosa del genere. È molto più brutta, e non tanto una rappresentazione diretta 1: 1 della macchina a stati.

(Nota come ho abilmente evitato di usare l'esempio "loop" noioso. Questo è un esempio in cui i PTC sono utili anche in una lingua con loop.)

Ho usato deliberatamente il termine "Chiamate di coda appropriate" qui invece di TCO. TCO è un'ottimizzazione del compilatore. PTC è una funzione del linguaggio che richiede che ogni compilatore esegua il TCO.


The vast majority of calls in a program are tail calls. Non se "la stragrande maggioranza" dei metodi chiamati esegue più di una propria chiamata. Every subroutine has a last call, so every subroutine has at least one tail call. Questo è banalmente dimostrabile come falso: return a + b. (A meno che tu non sia in un linguaggio folle in cui le operazioni aritmetiche di base sono definite come chiamate di funzione, ovviamente.)
Mason Wheeler,

1
"L'aggiunta di due numeri sta aggiungendo due numeri." Tranne le lingue in cui non lo è. Che dire dell'operazione + in Lisp / Scheme in cui un singolo operatore aritmetico può accettare un numero arbitrario di argomenti? (+ 1 2 3) L'unico modo sano di implementare è come una funzione.
Evicatos,

1
@Mason Wheeler: cosa intendi per inversione di astrazione?
Giorgio,

1
@MasonWheeler Questa è, senza dubbio, la voce di Wikipedia più ondulata su un argomento tecnico che io abbia mai visto. Ho visto alcune voci dubbie ma è solo ... wow.
Evicatos,

1
@MasonWheeler: Stai parlando delle funzioni di lunghezza dell'elenco alle pagine 22 e 23 di On Lisp? La versione di coda chiamata è circa 1,2 volte più complicata, da nessuna parte vicino a 3 volte. Non sono anche chiaro su cosa intendi per inversione di astrazione.
Michael Shaw,

4

"La JVM non supporta l'ottimizzazione delle chiamate in coda, quindi prevedo un sacco di pile esplosive"

Chiunque dica questo (1) non capisce l'ottimizzazione della coda, o (2) non capisce la JVM, o (3) entrambi.

Inizierò con la definizione delle chiamate di coda da Wikipedia (se non ti piace Wikipedia, ecco un'alternativa ):

Nell'informatica, una chiamata di coda è una chiamata di subroutine che avviene all'interno di un'altra procedura come azione finale; può produrre un valore di ritorno che viene quindi immediatamente restituito dalla procedura di chiamata

Nel codice seguente, la chiamata a bar()è la coda di foo():

private void foo() {
    // do something
    bar()
}

L'ottimizzazione delle chiamate di coda si verifica quando l'implementazione del linguaggio, vedendo una chiamata di coda, non utilizza il normale richiamo del metodo (che crea un frame di stack), ma crea invece un ramo. Questa è un'ottimizzazione perché un frame stack richiede memoria e richiede cicli CPU per inviare informazioni (come l'indirizzo di ritorno) sul frame e poiché si presume che la coppia call / return richieda più cicli CPU rispetto a un salto incondizionato.

Il TCO viene spesso applicato alla ricorsione, ma non è l'unico utilizzo. Né è applicabile a tutte le ricorsioni. Il semplice codice ricorsivo per calcolare un fattoriale, ad esempio, non può essere ottimizzato per la coda, poiché l'ultima cosa che accade nella funzione è un'operazione di moltiplicazione.

public static int fact(int n) {
    if (n <= 1) return 1;
    else return n * fact(n - 1);
}

Per implementare l'ottimizzazione delle chiamate di coda, hai bisogno di due cose:

  • Una piattaforma che supporta la diramazione oltre alle chiamate di subtroutine.
  • Un analizzatore statico in grado di determinare se è possibile l'ottimizzazione delle chiamate in coda.

Questo è tutto. Come ho notato altrove, la JVM (come qualsiasi altra architettura completa di Turing) ha un goto. Capita di avere un goto incondizionato , ma la funzionalità potrebbe essere facilmente implementata usando un ramo condizionale.

Il pezzo di analisi statica è ciò che è difficile. All'interno di una singola funzione, non è un problema. Ad esempio, ecco una funzione Scala ricorsiva della coda per sommare i valori in a List:

def sum(acc:Int, list:List[Int]) : Int = {
  if (list.isEmpty) acc
  else sum(acc + list.head, list.tail)
}

Questa funzione si trasforma nel seguente bytecode:

public int sum(int, scala.collection.immutable.List);
  Code:
   0:   aload_2
   1:   invokevirtual   #63; //Method scala/collection/immutable/List.isEmpty:()Z
   4:   ifeq    9
   7:   iload_1
   8:   ireturn
   9:   iload_1
   10:  aload_2
   11:  invokevirtual   #67; //Method scala/collection/immutable/List.head:()Ljava/lang/Object;
   14:  invokestatic    #73; //Method scala/runtime/BoxesRunTime.unboxToInt:(Ljava/lang/Object;)I
   17:  iadd
   18:  aload_2
   19:  invokevirtual   #76; //Method scala/collection/immutable/List.tail:()Ljava/lang/Object;
   22:  checkcast   #59; //class scala/collection/immutable/List
   25:  astore_2
   26:  istore_1
   27:  goto    0

Nota goto 0alla fine. In confronto, una funzione Java equivalente (che deve usare un Iteratorper imitare il comportamento di spezzare un elenco Scala in testa e coda) si trasforma nel seguente bytecode. Si noti che le ultime due operazioni sono ora invocate , seguite da un ritorno esplicito del valore prodotto da quella chiamata ricorsiva.

public static int sum(int, java.util.Iterator);
  Code:
   0:   aload_1
   1:   invokeinterface #64,  1; //InterfaceMethod java/util/Iterator.hasNext:()Z
   6:   ifne    11
   9:   iload_0
   10:  ireturn
   11:  iload_0
   12:  aload_1
   13:  invokeinterface #70,  1; //InterfaceMethod java/util/Iterator.next:()Ljava/lang/Object;
   18:  checkcast   #25; //class java/lang/Integer
   21:  invokevirtual   #74; //Method java/lang/Integer.intValue:()I
   24:  iadd
   25:  aload_1
   26:  invokestatic    #43; //Method sum:(ILjava/util/Iterator;)I
   29:  ireturn

L'ottimizzazione delle chiamate di coda di una singola funzione è banale: il compilatore può vedere che non esiste un codice che utilizza il risultato della chiamata, quindi può sostituire l' invocazione con a goto.

Dove la vita diventa complicata è se hai più metodi. Le istruzioni di diramazione della JVM, a differenza di quelle di un processore generico come 80x86, sono limitate a un singolo metodo. È ancora relativamente semplice se hai metodi privati: il compilatore è libero di incorporare quei metodi nel modo appropriato, quindi puoi ottimizzare le chiamate di coda (se ti stai chiedendo come potrebbe funzionare, considera un metodo comune che usa un switchper controllare il comportamento). Puoi anche estendere questa tecnica a più metodi pubblici nella stessa classe: il compilatore allinea i corpi del metodo, fornisce metodi di bridge pubblici e le chiamate interne si trasformano in salti.

Tuttavia, questo modello si interrompe quando si considerano i metodi pubblici in diverse classi, in particolare alla luce delle interfacce e dei programmi di caricamento classi. Il compilatore a livello di sorgente semplicemente non ha abbastanza conoscenze per implementare le ottimizzazioni di coda. Tuttavia, a differenza delle implementazioni "bare-metal", il * JVM (ha le informazioni per farlo, nella forma del compilatore Hotspot (almeno, il compilatore ex-Sun lo fa). Non so se effettivamente esegua ottimizzazioni di coda, e sospetto di no, ma potrebbe .

Il che mi porta alla seconda parte della tua domanda, che riformulerò come "dovremmo preoccuparci?"

Chiaramente, se la tua lingua usa la ricorsione come unica primitiva per l'iterazione, te ne importa. Ma le lingue che necessitano di questa funzionalità possono implementarla; l'unico problema è se un compilatore per detto linguaggio può produrre una classe che può chiamare ed essere chiamata da una classe Java arbitraria.

Al di fuori di quel caso, inviterò i voti negativi dicendo che è irrilevante. La maggior parte del codice ricorsivo che ho visto (e ho lavorato con molti progetti grafici) non è ottimizzabile in coda . Come il semplice fattoriale, utilizza la ricorsione per costruire lo stato e l'operazione di coda è una combinazione.

Per un codice ottimizzabile per le chiamate in coda, è spesso semplice tradurre quel codice in una forma iterabile. Ad esempio, quella sum()funzione che ho mostrato in precedenza può essere generalizzata come foldLeft(). Se guardi la fonte , vedrai che è effettivamente implementata come un'operazione iterativa. Jörg W Mittag aveva un esempio di macchina a stati implementata tramite chiamate di funzione; ci sono molte implementazioni efficienti (e gestibili) della macchina a stati che non si basano sulle chiamate di funzione tradotte in salti.

Finirò con qualcosa di completamente diverso. Se fai Google dalle note a piè di pagina nel SICP, potresti finire qui . Personalmente trovo che un posto molto più interessante che avere il mio compilatore sostituire JSRda JUMP.


Se esistesse un codice operativo di coda, perché l'ottimizzazione di coda richiede qualcosa di diverso dall'osservazione in ciascun sito di chiamata se il metodo che effettua la chiamata avrebbe bisogno di eseguire un codice in seguito? Può darsi che in alcuni casi un'istruzione simile return foo(123);possa essere eseguita meglio dall'in-lining foopiuttosto che dalla generazione di codice per manipolare lo stack ed eseguire un salto, ma non vedo perché tail-call sarebbe diverso da una normale chiamata in che riguardo.
supercat

@supercat - Non sono sicuro di quale sia la tua domanda. Il primo punto di questo post è che il compilatore non può sapere come potrebbe apparire il frame dello stack di tutti i potenziali callees (ricorda che il frame dello stack contiene non solo gli argomenti della funzione ma anche le sue variabili locali). Suppongo che potresti aggiungere un codice operativo che esegue un runtime per verificare i frame compatibili, ma che mi porta alla seconda parte del post: qual è il valore reale ?
kdgregory,
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.