Quali limiti impone la JVM all'ottimizzazione delle chiamate in coda


36

Clojure non esegue da solo l'ottimizzazione delle chiamate di coda: quando si dispone di una funzione ricorsiva di coda e si desidera ottimizzarla, è necessario utilizzare il modulo speciale recur. Allo stesso modo, se hai due funzioni reciprocamente ricorsive, puoi ottimizzarle solo usando trampoline.

Il compilatore Scala è in grado di eseguire il TCO per una funzione ricorsiva, ma non per due funzioni reciprocamente ricorsive.

Ogni volta che ho letto di queste limitazioni, sono sempre state attribuite a alcune limitazioni intrinseche al modello JVM. Non so praticamente nulla dei compilatori, ma questo mi confonde un po '. Vorrei prendere l'esempio da Programming Scala. Ecco la funzione

def approximate(guess: Double): Double =
  if (isGoodEnough(guess)) guess
  else approximate(improve(guess))

è tradotto in

0: aload_0
1: astore_3
2: aload_0
3: dload_1
4: invokevirtual #24; //Method isGoodEnough:(D)Z
7: ifeq
10: dload_1
11: dreturn
12: aload_0
13: dload_1
14: invokevirtual #27; //Method improve:(D)D
17: dstore_1
18: goto 2

Quindi, a livello di bytecode, basta uno goto. In questo caso, infatti, il compilatore esegue il duro lavoro.

Quale funzione della macchina virtuale sottostante consentirebbe al compilatore di gestire il TCO più facilmente?

Come nota a margine, non mi aspetto che le macchine reali siano molto più intelligenti della JVM. Tuttavia, molte lingue che si compilano in codice nativo, come Haskell, non sembrano avere problemi con l'ottimizzazione delle chiamate di coda (beh, a volte Haskell può avere a causa della pigrizia, ma questo è un altro problema).

Risposte:


25

Ora, non so molto su Clojure e poco su Scala, ma ci proverò.

Prima di tutto, dobbiamo distinguere tra CHIAMATE di coda e RICURSIONE di coda. La ricorsione della coda è davvero piuttosto facile da trasformare in un ciclo. Con le chiamate di coda, è molto più difficile da impossibile nel caso generale. Devi sapere come viene chiamato, ma con polimorfismo e / o funzioni di prima classe, raramente lo sai, quindi il compilatore non può sapere come sostituire la chiamata. Solo in fase di esecuzione conosci il codice target e puoi saltare lì senza allocare un altro stack frame. Ad esempio, il seguente frammento ha un richiamo di coda e non necessita di spazio di stack quando ottimizzato correttamente (incluso TCO), ma non può essere eliminato durante la compilazione per la JVM:

function forward(obj: Callable<int, int>, arg: int) =
    let arg1 <- arg + 1 in obj.call(arg1)

Mentre qui è solo un po 'inefficiente, ci sono interi stili di programmazione (come Continuation Passing Style o CPS) che hanno tonnellate di chiamate di coda e raramente ritornano mai. Farlo senza TCO completo significa che è possibile eseguire solo piccole quantità di codice prima di esaurire lo spazio dello stack.

Quale funzione della macchina virtuale sottostante consentirebbe al compilatore di gestire il TCO più facilmente?

Un'istruzione di chiamata di coda, come nella Lua 5.1 VM. Il tuo esempio non diventa molto più semplice. Il mio diventa qualcosa del genere:

push arg
push 1
add
load obj
tailcall Callable.call
// implicit return; stack frame was recycled

Come sidenote, non mi aspetto che le macchine reali siano molto più intelligenti della JVM.

Hai ragione, non lo sono. In realtà, sono meno intelligenti e quindi non sanno nemmeno (molto) cose come stack frame. Questo è esattamente il motivo per cui si possono fare trucchi come riutilizzare lo spazio dello stack e saltare al codice senza spingere un indirizzo di ritorno.


Vedo. Non mi rendevo conto che essere meno intelligenti poteva consentire un'ottimizzazione che sarebbe altrimenti vietata.
Andrea,

7
+1, le tailcallistruzioni per JVM sono già state proposte già nel 2007: Blog su sun.com attraverso la macchina del ritorno . Dopo l'acquisizione di Oracle, questo link 404. Immagino che non sia entrato nell'elenco delle priorità di JVM 7.
K.Steff,

1
tailcallUn'istruzione avrebbe segnato solo una chiamata coda come una chiamata coda. Se la JVM abbia effettivamente ottimizzato detta chiamata in coda è una domanda completamente diversa. CLI CIL ha un .tailprefisso di istruzione, ma il CLR a 64 bit di Microsoft per lungo tempo non lo ha ottimizzato. OTOH, l'IBM J9 JVM fa rilevare le chiamate di coda e li ottimizza, senza bisogno di un'istruzione speciale per dirgli che le chiamate sono le chiamate di coda. Annotare le chiamate di coda e ottimizzare le chiamate di coda sono veramente ortogonali. (A parte il fatto che dedurre staticamente quale chiamata è una chiamata di coda può o non può essere indecidibile. Non so.)
Jörg W Mittag

@ JörgWMittag Hai ragione, una JVM può facilmente rilevare il modello call something; oreturn. Il compito principale di un aggiornamento delle specifiche JVM non sarebbe quello di introdurre un'istruzione esplicita di coda ma di imporre che tale istruzione sia ottimizzata. Un'istruzione del genere semplifica solo i lavori degli autori di compilatori: l'autore di JVM non deve assicurarsi di riconoscere quella sequenza di istruzioni prima che venga alterata oltre il riconoscimento, e il compilatore X-> bytecode può essere certo che il loro bytecode non è valido o effettivamente ottimizzato, mai corretto, ma stack-overflow.

@delnan: la sequenza call something; return;equivarrebbe a una chiamata di coda se la cosa chiamata non richiede mai una traccia dello stack; se il metodo in questione è virtuale o chiama un metodo virtuale, la JVM non avrà modo di sapere se potrebbe informarsi sullo stack.
supercat

12

Clojure potrebbe eseguire l'ottimizzazione automatica della ricorsione della coda in loop: è certamente possibile farlo sulla JVM come dimostra Scala.

In realtà è stata una decisione di progettazione non farlo - devi usare esplicitamente il recurmodulo speciale se vuoi questa funzione. Vedi la discussione via mail Re: Perché nessuna ottimizzazione delle chiamate di coda sul gruppo Google Clojure.

Nell'attuale JVM, l'unica cosa che è impossibile fare è l'ottimizzazione del tail tail tra diverse funzioni (ricorsione reciproca). Questo non è particolarmente complesso da implementare (altri linguaggi come Scheme hanno questa funzionalità sin dall'inizio) ma richiederebbe modifiche alle specifiche JVM. Ad esempio, dovresti modificare le regole sulla conservazione dell'intero stack di chiamate di funzione.

È probabile che una futura iterazione di JVM ottenga questa funzionalità, anche se probabilmente come opzione in modo da mantenere un comportamento retrocompatibile per il vecchio codice. Ad esempio, Anteprima funzionalità su Geeknizer elenca questo per Java 9:

Aggiunta di chiamate di coda e continuazioni ...

Naturalmente, le future tabelle di marcia sono sempre soggette a modifiche.

A quanto pare, non è comunque un grosso problema. In oltre 2 anni di programmazione di Clojure, non ho mai incontrato una situazione in cui la mancanza di TCO era un problema. Le ragioni principali sono:

  • È già possibile ottenere una ricorsione rapida della coda per il 99% dei casi comuni con recuro un ciclo. Il caso di ricorsione della coda reciproca è piuttosto raro nel codice normale
  • Anche quando è necessaria una ricorsione reciproca, spesso la profondità di ricorsione è abbastanza bassa da poter essere comunque eseguita in pila senza TCO. TCO è solo una "ottimizzazione" dopo tutto ....
  • Nei casi molto rari in cui è necessaria una qualche forma di ricorsione reciproca che non consuma stack, ci sono molte altre alternative che possono raggiungere lo stesso obiettivo: sequenze pigre, trampolini ecc.

"iterazione futura" - L' anteprima delle funzionalità di Geeknizer dice per Java 9: aggiungere chiamate di coda e continuazioni - vero?
moscerino il

1
Sì, tutto qui. Naturalmente, le future tabelle di marcia sono sempre soggette a modifiche ....
mikera,

5

Come sidenote, non mi aspetto che le macchine reali siano molto più intelligenti della JVM.

Non si tratta di essere più intelligenti, ma di essere diversi. Fino a poco tempo fa, la JVM è stata progettata e ottimizzata esclusivamente per un singolo linguaggio (Java, ovviamente), che ha una memoria molto rigida e modelli di chiamata.

Non solo non c'erano gotoo puntatori, non c'era nemmeno alcun modo di chiamare una funzione 'nuda' (una che non era un metodo definito all'interno di una classe).

Concettualmente, quando si rivolge a JVM, uno scrittore di compilatori deve chiedere "come posso esprimere questo concetto in termini Java?". E ovviamente, non c'è modo di esprimere il TCO in Java.

Si noti che questi non sono visti come guasti di JVM, perché non sono necessari per Java. Non appena Java ha bisogno di alcune funzionalità come queste, viene aggiunto a JVM.

È solo di recente che le autorità Java hanno iniziato a prendere sul serio JVM come piattaforma per linguaggi non Java, quindi ha già ottenuto un supporto per funzionalità che non hanno equivalenti Java. Il più noto è la digitazione dinamica, che è già in JVM ma non in Java.


3

Quindi, a livello di bytecode, basta andare a goto. In questo caso, infatti, il compilatore esegue il duro lavoro.

Hai notato che l'indirizzo del metodo inizia con 0? Che tutti i metodi di set iniziano con 0? JVM non consente di saltare fuori da un metodo.

Non ho idea di cosa succederebbe con un ramo con offset al di fuori del metodo che è stato caricato da Java - forse sarebbe stato catturato dal verificatore bytecode, forse avrebbe generato un'eccezione e forse sarebbe effettivamente saltato fuori dal metodo.

Il problema, ovviamente, è che non puoi davvero garantire dove saranno gli altri metodi della stessa classe, e tanto meno i metodi di altre classi. Dubito che JVM fornisca garanzie su dove caricherà i metodi, anche se sarei felice di essere corretto.


Buon punto. Ma per chiamare in coda ottimizzare una funzione ricorsiva, tutto ciò che serve è un GOTO all'interno dello stesso metodo . Quindi questa limitazione non esclude il TCO dei metodi auto-ricorsivi.
Alex D,
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.