it-swarm.com.de

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

Clojure führt keine Tail-Call-Optimierung alleine durch: Wenn Sie eine Tail-Rekursionsfunktion haben und diese optimieren möchten, müssen Sie das spezielle Formular recur verwenden. Wenn Sie zwei gegenseitig rekursive Funktionen haben, können Sie diese ebenfalls nur mit trampoline optimieren.

Der Compiler Scala Compiler) kann TCO für eine rekursive Funktion ausführen, jedoch nicht für zwei 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 von 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 erledigt der Compiler die harte Arbeit.

Welche Funktion 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. Dennoch scheinen viele Sprachen, die mit nativem Code kompiliert werden, wie z. B. Haskell, keine Probleme mit der Optimierung von Tail-Aufrufen zu haben (nun, Haskell kann manchmal aufgrund von Faulheit auftreten, aber das ist ein anderes Problem).

36
Andrea

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. Bei Tail Calls ist es im allgemeinen Fall viel schwieriger bis unmöglich. Sie müssen wissen, wie aufgerufen wird, aber mit Polymorphismus und/oder erstklassigen Funktionen wissen Sie das selten, sodass der Compiler nicht wissen kann, wie er den Aufruf ersetzen soll. Nur zur Laufzeit kennen Sie den Zielcode und können dorthin springen, ohne einen weiteren Stapelrahmen zuzuweisen. Das folgende Fragment hat beispielsweise einen Tail-Aufruf und benötigt bei ordnungsgemäßer Optimierung (einschließlich TCO) keinen Stapelspeicherplatz, 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 Tonnen von Tail Calls haben und selten zurückkehren. Wenn Sie dies ohne vollständige Gesamtbetriebskosten tun, können Sie nur winzige Codebits ausführen, bevor Ihnen der Stapelspeicherplatz ausgeht.

Welche Funktion 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 so etwas wie:

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

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

Du hast recht, das sind sie nicht. Tatsächlich sind sie weniger klug und wissen daher nicht einmal (viel) über Dinge wie Stapelrahmen. Genau deshalb kann man Tricks wie die Wiederverwendung des Stapelspeichers und das Springen zum Code anwenden, ohne eine Absenderadresse zu drücken.

26
user7043

Clojure könnte eine automatische Optimierung der Schwanzrekursion in Schleifen durchführen: Es ist sicherlich möglich, dies in der JVM als Scala) zu tun beweist.

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

In der aktuellen JVM ist das einzige, was unmöglich ist, die Tail-Call-Optimierung zwischen verschiedenen Funktionen (gegenseitige Rekursion). Die Implementierung ist nicht besonders komplex (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 vollständigen Funktionsaufrufstapels ändern.

Eine zukünftige Iteration der JVM wird diese Funktion wahrscheinlich erhalten, jedoch wahrscheinlich als Option, damit das abwärtskompatible Verhalten für alten Code beibehalten wird. Angenommen, Funktionsvorscha bei Geeknizer listet dies für Java 9:

Tail Calls und Fortsetzungen hinzufügen ...

Zukünftige Roadmaps können sich natürlich immer ä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 das Fehlen von TCO ein Problem war. Die Hauptgründe dafür sind:

  • Mit recur oder einer Schleife können Sie bereits für 99% der häufigsten Fälle eine schnelle Schwanzrekursion 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 ohnehin ohne Gesamtbetriebskosten auf dem Stapel ausführen können. TCO ist schließlich nur eine "Optimierung" ....
  • In den sehr seltenen Fällen, in denen Sie eine Form der nicht stapelaufwendigen gegenseitigen Rekursion benötigen, gibt es viele andere Alternativen, die das gleiche Ziel erreichen können: faule Sequenzen, Trampoline usw.
12
mikera

Als Nebenbemerkung würde ich nicht erwarten, dass tatsächliche 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 (natürlich Java) entwickelt und optimiert, die über sehr strenge Speicher- und Aufrufmodelle verfügt.

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

Konzeptionell muss sich ein Compiler-Writer beim Targeting von JVM fragen: "Wie kann ich dieses Konzept in Java Begriffen?" Ausdrücken? ". Und natürlich gibt es keine Möglichkeit, TCO 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 eine solche Funktion benötigt, wird sie zu JVM hinzugefügt.

Es ist erst vor kurzem, dass die Java Behörden damit begonnen haben, JVM als Plattform für Nicht-Java-Sprachen ernst zu nehmen, daher hat es bereits einige Unterstützung für Funktionen erhalten, die kein Java) haben = Äquivalent. Am bekanntesten ist die dynamische Typisierung, die bereits in JVM, jedoch nicht in Java vorhanden ist.

5
Javier

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

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

Ich habe keine Ahnung, was mit einem Zweig mit Offset außerhalb der Methode passieren würde, der von Java - - möglicherweise vom Bytecode-Verifizierer abgefangen wird, möglicherweise eine Ausnahme generiert und möglicherweise würde 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 würde.

3