JVMがテールコールの最適化に課す制限


36

Clojureは、単独で末尾呼び出しの最適化を実行しません。末尾再帰関数があり、それを最適化する場合は、特別な形式を使用する必要がありますrecur。同様に、相互に再帰的な関数が2つある場合、を使用してのみ最適化できますtrampoline

Scalaコンパイラは、再帰関数に対してTCOを実行できますが、2つの相互再帰関数に対してはできません。

これらの制限について読んだときはいつでも、JVMモデルに固有の制限に常に起因していました。コンパイラについてはほとんど何も知りませんが、これは少し困惑します。から例を見てみましょうProgramming Scala。ここで機能

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

に翻訳されています

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

そのため、バイトコードレベルでは、が必要gotoです。この場合、実際には、ハードワークはコンパイラーによって行われます。

基礎となる仮想マシンのどの機能により、コンパイラはTCOをより簡単に処理できますか?

サイドノートとして、私は実際のマシンがJVMよりもはるかに賢いとは思わないでしょう。それでも、Haskellなどのネイティブコードにコンパイルする多くの言語では、テールコールの最適化に問題はないようです(もちろん、Haskellは怠のために時々発生する可能性がありますが、それは別の問題です)。

回答:


25

今、私はClojureについてあまり知らず、Scalaについてもほとんど知りませんが、試してみます。

まず、tail-CALLとtail-RECURSIONを区別する必要があります。末尾再帰は、実際、ループに変換するのがかなり簡単です。テールコールを使用すると、一般的な場合では不可能またははるかに困難になります。何が呼び出されているかを知る必要がありますが、ポリモーフィズムやファーストクラスの関数では、それをほとんど知ることができないため、コンパイラは呼び出しを置き換える方法を知ることができません。実行時にのみターゲットコードを知っており、別のスタックフレームを割り当てずにそこにジャンプできます。たとえば、次のフラグメントには末尾呼び出しがあり、適切に最適化されている場合(TCOを含む)、スタックスペースは必要ありませんが、JVM用にコンパイルする場合は削除できません。

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

ここでは少し非効率的ですが、大量のテールコールがあり、めったに戻ることのないプログラミングスタイル(Continuation Passing StyleやCPSなど)があります。完全なTCOを使用せずにこれを行うと、スタックスペースが不足する前にほんの少しのコードしか実行できなくなります。

基礎となる仮想マシンのどの機能により、コンパイラはTCOをより簡単に処理できますか?

Lua 5.1 VMなどのテールコール命令。あなたの例はそれほど単純ではありません。私のようなものになります:

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

補足として、私は実際のマシンがJVMよりもはるかに賢いとは思わないでしょう。

あなたは正しい、彼らはそうではありません。実際、それらはあまりスマートではないため、スタックフレームなどのことも(あまり)知りません。これがまさに、スタックアドレスを再利用したり、リターンアドレスをプッシュせずにコードにジャンプしたりするトリックを引き出すことができる理由です。


そうですか。あまり賢くないと、他の方法では禁止されている最適化が可能になるとは思いませんでした。
アンドレア

7
+ 1、tailcallJVMの指示は2007年にはすでに提案されています:ウェイバックマシンを介したsun.comのブログ。Oracleの買収後、このリンクは404になります。JVM 7の優先順位リストに入れなかったと思います。
K.ステフ

1
tailcall命令は、末尾呼び出しとして末尾呼び出しをマークします。JVMが実際に前述のテールコールを最適化するかどうかは、まったく異なる質問です。CLI CILには.tail命令プレフィックスがありますが、Microsoft 64ビットCLRは長い間それを最適化しませんでした。OTOH、IBM J9 JVM テールコールを検出し、最適化します。どのコールがテールコールであるかを通知する特別な命令を必要としません。テールコールの注釈付けとテールコールの最適化は、実際には直交しています。(どのコールがテールコールであるかを静的に推定することは、決定できない場合とできない場合があります。ダンノ。)
ヨルグWミットタグ

@JörgWMittag重要なのは、JVMがパターンを簡単に検出できることcall something; oreturnです。JVM仕様の更新の主な仕事は、明示的な末尾呼び出し命令を導入することではなく、そのような命令を最適化することを義務付けることです。このような命令は、コンパイラライターの仕事を簡単にするだけです。JVMの作成者は、認識できないほどマングルされる前に、その命令シーケンスを認識することを確認する必要はなく、X-> bytecodeコンパイラは、バイトコードが無効または実際には最適化されており、決して正しくはありませんが、スタックがあふれています。

@delnan:call something; return;呼び出されるものがスタックトレースを決して要求しない場合にのみ、シーケンスは末尾呼び出しと同等になります。問題のメソッドが仮想メソッドである場合、または仮想メソッドを呼び出す場合、JVMはスタックについて照会できるかどうかを知る方法がありません。
supercat

12

Clojure 、末尾再帰のループへの自動最適化を実行できます。Scala 証明しているように、JVMでこれを行うことは確かに可能です。

実際には、これを行わないことは設計上の決定でしたrecur。この機能が必要な場合は、特別な形式を明示的に使用する必要があります。メールスレッドを参照してください。ClojureのGoogleグループでテールコールの最適化がない理由

現在のJVMでは、実行できないのは、異なる関数間の末尾呼び出しの最適化(相互再帰)だけです。これは実装するのに特に複雑ではありません(Schemeなどの他の言語には最初からこの機能がありました)が、JVM仕様の変更が必要になります。たとえば、完全な関数呼び出しスタックの保存に関するルールを変更する必要があります。

JVMの将来の反復では、おそらく古いコードの下位互換性のある動作を維持するためのオプションとして、この機能が得られる可能性があります。セイは、プレビュー機能のJava 9用Geeknizerリストでこれを:

テールコールと継続の追加...

もちろん、将来のロードマップは常に変更される可能性があります。

結局のところ、それはとにかくそれほど大したことではありません。Clojureをコーディングして2年以上が経ちましたが、TCOの不足が問題となる状況に遭遇したことは一度もありません。この主な理由は次のとおりです。

  • recurループのある一般的なケースの99%ですでに高速の末尾再帰を取得できます。相互末尾再帰のケースは、通常のコードではかなりまれです
  • 相互再帰が必要な場合でも、多くの場合、再帰の深さは十分に浅いため、TCOなしでスタック上でそれを行うことができます。TCOは結局のところ「最適化」に過ぎません。
  • スタックを消費しない相互再帰の何らかの形を必要とする非常にまれなケースでは、同じ目的を達成できる他の選択肢がたくさんあります:遅延シーケンス、トランポリンなど。

「将来のイテレーション」-Geeknizerの機能プレビューはJava 9について次のように述べています:テールコールと継続の追加 -それですか?
ブヨ

1
うん-それだけです。もちろん、将来のロードマップは....常に変更されることがあります
mikera

5

補足として、私は実際のマシンがJVMよりもはるかに賢いとは思わないでしょう。

それは賢いことではなく、違うことです。最近まで、JVMは非常に厳密なメモリと呼び出しモデルを備えた単一言語(明らかにJava)専用に設計および最適化されていました。

gotoポインタやポインタがなかっただけでなく、「裸の」関数(クラス内で定義されたメソッドではなかった関数)を呼び出す方法すらありませんでした。

概念的には、JVMを対象とする場合、コンパイラ作成者は「この概念をJavaの用語で表現するにはどうすればよいですか」と尋ねる必要があります。そして明らかに、TCOをJavaで表現する方法はありません。

これらはJavaには必要ないため、JVMの障害とは見なされないことに注意してください。Javaがこれらのような機能を必要とするとすぐに、JVMに追加されます。

Java当局がJVMを非Java言語のプラットフォームとして真剣に考え始めたのはごく最近のことなので、Javaに相当するものがない機能のサポートをすでに獲得しています。最もよく知られているのは、JavaではなくJVMに既に存在する動的型付けです。


3

したがって、バイトコードレベルでは、gotoが必要です。この場合、実際には、ハードワークはコンパイラーによって行われます。

メソッドのアドレスが0で始まることに気づきましたか?そのすべてのメソッドのofsetsが0で始まりますか?JVMでは、メソッドの外にジャンプすることはできません。

javaによってロードされたメソッドの外側にオフセットがあるブランチで何が起こるか分かりません-バイトコード検証によってキャッチされ、例外を生成し、実際にメソッドの外にジャンプする可能性があります。

もちろん、問題は、同じクラスの他のメソッドがどこにあるか、他のクラスのメソッドがはるかに少ないことを実際に保証できないことです。JVMがメソッドをどこにロードするかを保証するのではないかと思いますが、喜んで修正します。


いい視点ね。ただし、自己再帰関数を末尾呼び出しで最適化するには、同じメソッド内の GOTOが必要です。したがって、この制限は自己再帰メソッドのTCOを排除しません。
アレックスD
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.