Welche Einschränkungen hat die JVM für die Tail-Call-Optimierung

36

Clojure führt die Tail-Call-Optimierung nicht alleine durch: Wenn Sie eine Tail-Recursive-Funktion haben und diese optimieren möchten, müssen Sie das spezielle Formular verwenden recur. Wenn Sie zwei sich gegenseitig rekursive Funktionen haben, können Sie diese ebenfalls nur mithilfe von optimieren trampoline.

Der Scala-Compiler kann TCO für eine rekursive Funktion ausführen, jedoch nicht für zwei sich gegenseitig rekursive Funktionen.

Wann immer ich über diese Einschränkungen gelesen habe, wurden sie immer einer Einschränkung zugeschrieben, die dem JVM-Modell eigen ist. Ich weiß so ziemlich nichts über Compiler, aber das verwirrt mich ein bisschen. Lassen Sie mich das Beispiel nehmen Programming Scala. Hier die Funktion

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

wird übersetzt 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

Auf Bytecode-Ebene braucht man also nur goto. In diesem Fall wird die harte Arbeit vom Compiler erledigt.

Welche Einrichtung der zugrunde liegenden virtuellen Maschine würde es dem Compiler ermöglichen, die Gesamtbetriebskosten einfacher zu handhaben?

Als Randnotiz würde ich nicht erwarten, dass tatsächliche Maschinen viel schlauer sind als die JVM. Trotzdem scheinen viele Sprachen, die mit nativem Code kompiliert werden, wie z. B. Haskell, keine Probleme mit der Optimierung von Tail Calls zu haben (Haskell kann manchmal aufgrund von Faulheit Probleme haben, aber das ist ein anderes Problem).

Andrea
quelle

Antworten:

25

Jetzt weiß ich nicht viel über Clojure und wenig über Scala, aber ich werde es versuchen.

Zunächst müssen wir zwischen Tail-CALLs und Tail-RECURSION unterscheiden. Die Schwanzrekursion ist in der Tat ziemlich einfach in eine Schleife umzuwandeln. Mit Tail Calls ist es im allgemeinen Fall viel schwieriger bis unmöglich. Sie müssen wissen, was aufgerufen wird, aber mit Polymorphismus und / oder erstklassigen Funktionen wissen Sie das selten, sodass der Compiler nicht wissen kann, wie der Aufruf ersetzt werden soll. Nur zur Laufzeit kennen Sie den Zielcode und können dorthin springen, ohne einen weiteren Stack-Frame zuzuweisen. Das folgende Fragment hat beispielsweise einen Tail-Aufruf und benötigt bei ordnungsgemäßer Optimierung (einschließlich TCO) keinen Stapelspeicher, kann jedoch beim Kompilieren für die JVM nicht entfernt werden:

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

Während es hier nur ein bisschen ineffizient ist, gibt es ganze Programmierstile (wie Continuation Passing Style oder CPS), die jede Menge Tail Calls haben und selten zurückkehren. Wenn Sie dies ohne volle Gesamtbetriebskosten tun, können Sie nur winzige Code-Bits ausführen, bevor der Stapelspeicherplatz erschöpft ist.

Welche Einrichtung der zugrunde liegenden virtuellen Maschine würde es dem Compiler ermöglichen, die Gesamtbetriebskosten einfacher zu handhaben?

Eine Tail-Call-Anweisung wie in der Lua 5.1 VM. Ihr Beispiel wird nicht viel einfacher. Meins wird ungefähr so:

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

Als Nebenbemerkung würde ich nicht erwarten, dass die tatsächlichen Maschinen viel intelligenter sind als die JVM.

Du hast recht, sie sind nicht. Tatsächlich sind sie weniger schlau und wissen daher nicht einmal (viel) über Dinge wie Stapelrahmen. Das ist genau der Grund, warum man Tricks wie die Wiederverwendung des Stapelspeichers und das Springen zum Code ohne Drücken einer Rücksprungadresse anwenden kann.


quelle
Aha. Mir war nicht klar, dass weniger klug eine Optimierung ermöglichen könnte, die sonst verboten wäre.
Andrea
7
+1, tailcallAnweisung für JVM wurde bereits 2007 vorgeschlagen: Blog auf sun.com über die Wayback-Maschine . Nach der Übernahme von Oracle ist dieser Link 404's. Ich schätze, es hat es nicht in die Prioritätenliste von JVM 7 geschafft.
K.Steff
1
Eine tailcallAnweisung würde einen Tail Call nur als Tail Call kennzeichnen. Ob die JVM diesen Tail Call dann tatsächlich optimiert hat, ist eine ganz andere Frage. Die CLI-CIL hat ein .tailAnweisungspräfix, die Microsoft 64-Bit-CLR hat es jedoch lange Zeit nicht optimiert. OTOH, die IBM J9 JVM erkennt Tail Calls und optimiert sie, ohne dass eine spezielle Anweisung erforderlich ist, um festzustellen, welche Calls Tail Calls sind. Das Kommentieren von Tail Calls und das Optimieren von Tail Calls sind wirklich orthogonal. (Abgesehen von der Tatsache, dass das statische Ableiten, welcher Anruf ein Tail-Anruf ist, möglicherweise nicht entscheidbar ist oder auch nicht. Keine Ahnung.)
Jörg W Mittag
@ JörgWMittag Du machst einen guten Punkt, eine JVM kann das Muster leicht erkennen call something; oreturn. Die Hauptaufgabe eines JVM-Spezifikationsupdates besteht darin, keine explizite Anweisung für den Endanruf einzuführen, sondern die Optimierung einer solchen Anweisung anzuweisen. Eine solche Anweisung erleichtert nur die Arbeit der Compilerautoren: Der JVM-Autor muss diese Anweisungssequenz nicht unbedingt erkennen, bevor sie unkenntlich gemacht wird, und der X-> Bytecode-Compiler kann sicher sein, dass ihr Bytecode entweder ungültig oder ungültig ist eigentlich optimiert, nie richtig, aber Stapelüberlauf.
@delnan: Die Sequenz call something; return;würde nur dann einem Tail-Aufruf entsprechen, wenn das Ding, das aufgerufen wird, niemals nach einem Stack-Trace fragt. Wenn die betreffende Methode virtuell ist oder eine virtuelle Methode aufruft, kann die JVM nicht wissen, ob sie nach dem Stapel fragen kann.
Supercat
12

Clojure könnte die automatische Optimierung der Schwanzrekursion in Schleifen durchführen. Wie Scala beweist, ist dies auf der JVM durchaus möglich.

Es war eigentlich eine Designentscheidung, dies nicht zu tun - Sie müssen das recurspezielle Formular explizit verwenden , wenn Sie diese Funktion wünschen. Siehe den Mail-Thread Betreff: Warum keine Tail-Call-Optimierung in der Google-Gruppe von Clojure.

In der aktuellen JVM ist die Optimierung des Tail Calls zwischen verschiedenen Funktionen (gegenseitige Rekursion) nur unmöglich. Die Implementierung ist nicht besonders kompliziert (andere Sprachen wie Scheme hatten diese Funktion von Anfang an), erfordert jedoch Änderungen an der JVM-Spezifikation. Beispielsweise müssten Sie die Regeln zum Beibehalten des gesamten Funktionsaufrufstapels ändern.

Eine zukünftige Iteration der JVM wird wahrscheinlich diese Funktion erhalten, möglicherweise jedoch als Option, damit das abwärtskompatible Verhalten für alten Code beibehalten wird. Sagen wir , Features Preview bei Geeknizer listet dies für Java 9 auf:

Tail Calls und Fortsetzungen hinzufügen ...

Natürlich können sich zukünftige Roadmaps jederzeit ändern.

Wie sich herausstellt, ist es sowieso keine so große Sache. In über 2 Jahren der Codierung von Clojure bin ich nie auf eine Situation gestoßen, in der der Mangel an TCO ein Problem war. Die Hauptgründe dafür sind:

  • Sie können bereits für 99% der häufigsten Fälle recureine schnelle Schwanzrekursion mit oder einer Schleife erhalten. Der Fall der gegenseitigen Schwanzrekursion ist im normalen Code ziemlich selten
  • Selbst wenn Sie eine gegenseitige Rekursion benötigen, ist die Rekursionstiefe häufig so gering, dass Sie sie auch ohne TCO auf dem Stapel ausführen können. TCO ist doch nur eine "Optimierung" ....
  • In den sehr seltenen Fällen, in denen Sie eine Form von nicht stapelaufwendiger gegenseitiger Rekursion benötigen, gibt es viele andere Alternativen, die dasselbe Ziel erreichen können: faule Sequenzen, Trampoline usw.
mikera
quelle
"Future Iteration" - Feature Preview bei Geeknizer sagt für Java 9: Tail Calls und Fortsetzungen hinzufügen - oder?
gnat
1
Ja, das ist es. Natürlich sind zukünftige Roadmaps immer
freibleibend
5

Als Nebenbemerkung würde ich nicht erwarten, dass die tatsächlichen Maschinen viel intelligenter sind als die JVM.

Es geht nicht darum, schlauer zu sein, sondern anders zu sein. Bis vor kurzem wurde die JVM ausschließlich für eine einzige Sprache (Java, offensichtlich) entwickelt und optimiert, die über sehr strenge Speicher- und Aufrufmodelle verfügt.

Es gab nicht nur keine gotooder Zeiger, es gab auch keine Möglichkeit, eine "nackte" Funktion aufzurufen (eine, die keine in einer Klasse definierte Methode war).

Wenn ein Compiler auf JVM abzielt, muss er sich konzeptionell die Frage stellen, wie dieses Konzept in Java ausgedrückt werden kann. Und offensichtlich gibt es keine Möglichkeit, die Gesamtbetriebskosten in Java auszudrücken.

Beachten Sie, dass diese nicht als Fehler von JVM angesehen werden, da sie für Java nicht benötigt werden. Sobald Java ein solches Feature benötigt, wird es zu JVM hinzugefügt.

Erst vor kurzem haben die Java-Behörden begonnen, JVM als Plattform für Nicht-Java-Sprachen ernst zu nehmen. Daher wurden bereits einige Funktionen unterstützt, die kein Java-Äquivalent aufweisen. Am bekanntesten ist die dynamische Typisierung, die bereits in JVM, jedoch nicht in Java vorhanden ist.

Javier
quelle
3

Auf der Bytecode-Ebene muss man also nur gehen. In diesem Fall wird die harte Arbeit vom Compiler erledigt.

Haben Sie bemerkt, dass die Methodenadresse mit 0 beginnt? Dass alle Methoden von Sets mit 0 beginnen? JVM erlaubt es keinem, außerhalb einer Methode zu springen.

Ich habe keine Ahnung, was mit einem Zweig passieren würde, dessen Offset außerhalb der Methode von Java geladen wurde. Vielleicht würde er vom Bytecode-Verifizierer abgefangen, vielleicht würde er eine Ausnahme erzeugen und vielleicht tatsächlich außerhalb der Methode springen.

Das Problem ist natürlich, dass Sie nicht wirklich garantieren können, wo sich andere Methoden derselben Klasse befinden, geschweige denn Methoden anderer Klassen. Ich bezweifle, dass JVM irgendwelche Garantien darüber gibt, wo die Methoden geladen werden, obwohl ich gerne korrigiert werden würde.

Daniel C. Sobral
quelle
Guter Punkt. Für die Optimierung einer selbstrekursiven Funktion per Tail-Call ist jedoch nur ein GOTO mit derselben Methode erforderlich . Diese Einschränkung schließt also die Gesamtbetriebskosten selbstrekursiver Methoden nicht aus.
Alex D