TCOがない場合、スタックを爆破することを心配する場合


14

JVMを対象とする新しいプログラミング言語についての議論があるたびに、必然的に次のようなことを言っている人々がいます。

「JVMはテールコールの最適化をサポートしていないため、大量のスタックの爆発を予測しています」

そのテーマには何千ものバリエーションがあります。

今では、Clojureなどの一部の言語には、使用できる特別な再帰構造があります。

私が理解していないのは、テールコール最適化の欠如がどれほど深刻なのかということです。いつ心配する必要がありますか?

私の混乱の主な原因は、おそらくJavaがこれまでで最も成功した言語の1つであり、JVM言語のかなりの数がかなりうまくいっているように思われるという事実からでしょう。TCOの欠如が本当にあるならばどのようにそれが可能である任意の懸念?


4
TCOなしでスタックを爆破するのに十分な深さの再帰がある場合は、TCOでも問題が発生します
ラチェットフリーク

18
@ratchet_freakそれはナンセンスです。Schemeにはループすらありませんが、仕様ではTCOのサポートが義務付けられているため、多数のデータセットの再帰的な反復は命令型ループよりも高価ではありません(Schemeコンストラクトが値を返すというボーナスがあります)。
-itsbruce

6
@ratchetfreak TCOは、特定の方法(つまり、末尾再帰)で記述された再帰関数を、たとえ望んだとしても完全にスタックを爆破できないようにするメカニズムです。あなたの文は末尾再帰で書かれていない再帰に対してのみ意味があります。その場合、あなたは正しいです、そしてTCOはあなたを助けません。
エヴィカトス

2
最後に見たところ、80x86は(ネイティブの)テールコール最適化も行いません。しかし、それは言語開発者がそれを使用する言語を移植することを止めていません。コンパイラは、ジャンプとjsrをいつ使用できるかを特定し、誰もが満足しています。JVMでも同じことができます。
kdgregory

3
@kdgregory:しかし、x86にはGOTOありますが、JVMにはありません。また、x86は相互運用プラットフォームとしては使用されません。JVMにはありませんGOTO。Javaプラットフォームを選択する主な理由の1つは相互運用性です。JVMにTCOを実装する場合は、スタックに対して何かをする必要があります。自分で管理し(つまり、JVMコールスタックをまったく使用しない)、トランポリンを使用しGOTO、例外を使用します。これらのすべてのケースで、JVM呼び出しスタックとの互換性がなくなります。Javaとスタック互換性があり、TCOがあり、パフォーマンスが高いことは不可能です。これらの3つのうちの1つを犠牲にしなければなりません。
ヨルグWミットタグ

回答:


16

これを考慮して、Javaのすべてのループを削除したとしましょう(コンパイラライターはストライキか何かにかかっています)。今階乗を書きたいので、このようなものを正しくするかもしれません

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);
}

今、私たちはかなり賢く感じています。ループなしでも階乗を書くことができました!しかし、テストすると、適切なサイズの数値では、TCOがないため、スタックオーバーフローエラーが発生していることがわかります。

実際のJavaでは、これは問題ではありません。末尾再帰アルゴリズムがある場合、それをループに変換して問題なく実行できます。しかし、ループのない言語はどうでしょうか?それからちょうどあなたはホースでくまれる。そのため、clojureにはこのrecur形式があります。これがないと、完全なチューリングすらできません(無限ループを行う方法はありません)。

JVMをターゲットとする関数型言語のクラス、Frege、Kawa(Scheme)、Clojureは、末尾呼び出しの欠如に常に対処しようとしています。これらの言語では、TCがループを行う慣用的な方法だからです!Schemeに変換すると、上記の階乗は良い階乗になります。5000回ループするとプログラムがクラッシュするのは非常に不便です。ただし、これは、recur特別な形式、自己呼び出しの最適化を示唆する注釈、トランポリンなどを使用して回避できます。しかし、それらはすべて、パフォーマンスの低下またはプログラマーへの不必要な作業を強制します。

現在、JavaはTCOだけでなく、再帰だけでなく、相互再帰関数についてはどうでしょうか。これらはループに直接変換することはできませんが、JVMによってまだ最適化されていません。適切なパフォーマンス/範囲が必要な場合は、ループに収まるようにダークマジックを実行する必要があるため、Javaを使用して相互再帰を使用してアルゴリズムを記述しようとすると、非常に不快になります。

したがって、要約すると、これは多くの場合大きな問題ではありません。ほとんどのテールコールは、次のように1スタックフレーム分だけ進む

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

または再帰です。ただし、これに適合しないTCのクラスでは、すべてのJVM言語が痛みを感じます。

しかし、まだTCOがないのはまともな理由があります。JVMはスタックトレースを提供します。TCOを使用すると、「ズーム」されていることがわかっているスタックフレームを体系的に削除しますが、JVMは実際に後でスタックトレースのためにこれらを必要とする場合があります。このようなFSMを実装し、各状態が次を呼び出すとしましょう。以前の状態のすべてのレコードを消去して、トレースバックがどの状態を示したかを示しますが、どのようにしてそこに到達したかについては何も示しません。

さらに、より差し迫ったことに、バイトコードの検証の多くはスタックベースであるため、バイトコードを検証することを望んでいないものを排除できます。これとJavaにループがあるという事実の間で、TCOはJVMエンジニアにとって価値があるよりも少し厄介に見えます。


2
最大の問題は、完全にスタック検査に基づいているバイトコード検証機能です。これは、JVM仕様の主要なバグです。25年前、JVMが設計されたとき、人々はすでに、JVMバイトコード言語を安全ではなく、そのコードを事実の後にバイトコード検証に頼るよりも、そもそも安全にした方が良いと言っていました。ただし、Matthias Felleisen(Schemeコミュニティの主要人物の1人)は、バイトコード検証を保持しながら、JVMにテールコールを追加する方法を示す論文を書きました。
ヨルグWミットタグ

2
興味深いことに、IBMのJ9 JVM TCOを実行ます。
ヨルグWミットタグ

1
@jozefg興味深いことに、ループのスタックトレースエントリは誰も気にしません。したがって、少なくとも末尾再帰関数の場合、stacktrace引数には水が入りません。
インゴ

2
@MasonWheelerそれはまさに私のポイントです。スタックトレースは、それがどの反復で発生したかを教えてくれません。これは、ループ変数などを調べることで間接的にしか見ることができません。では、なぜ、末尾再帰関数のいくつかのhundertスタックトレースエントリが必要なのでしょうか。最後のものだけが面白いです!そして、ループと同じように、あなたはそれがなど地元varaibles、引数の値を、調べることであった再帰決定することができる
インゴ・

3
@Ingo:関数がそれ自体でのみ再帰する場合、スタックトレースはあまり表示されないことがあります。ただし、関数のグループが相互に再帰的である場合、スタックトレースが大量に表示されることがあります。
-supercat

7

末尾再帰のため、末尾呼び出しの最適化は主に重要です。ただし、JVMがテールコールを最適化しないことが実際に良い理由には、TCOがスタックの一部を再利用するため、例外からのスタックトレースが不完全になるため、デバッグが少し難しくなります。

JVMの制限を回避する方法があります。

  1. 単純な末尾再帰は、コンパイラーによってループに最適化できます。
  2. プログラムが継続渡しスタイルの場合、「トランポリン」を使用するのは簡単です。ここでは、関数は最終結果を返さず、継続を外部で実行します。この手法により、コンパイラの作成者は、任意の複雑な制御フローをモデル化できます。

これには、より大きな例が必要になる場合があります。クロージャを持つ言語を検討してください(JavaScriptなど)。階乗を次のように書くことができます

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

print fac(x)

これで、代わりにコールバックを返すことができます:

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

これは、一定のスタックスペースで動作するようになりました。とにかく末尾再帰であるため、これは馬鹿げています。ただし、この手法では、すべての末尾呼び出しを一定のスタックスペースにフラット化できます。また、プログラムがCPSにある場合、これはコールスタックが全体的に一定であることを意味します(CPSでは、すべての呼び出しは末尾呼び出しです)。

この手法の主な欠点は、デバッグがはるかに難しく、実装が少し難しく、パフォーマンスが低いことです。使用しているすべてのクロージャーとインダイレクションを参照してください。

これらの理由から、VMにテールコールopを実装させることが非常に望ましいでしょう。テールコールをサポートしない正当な理由があるJavaのような言語は、それを使用する必要はありません。


1
「TCOはスタックの一部を再利用するため、例外からのスタックトレースは不完全になります」-はい。-残念ながら、たとえJVMが適切なテールコールをサポートする場合でも、デバッグ中にオプトアウトできます。そして、本番環境では、TCOを有効にして、コードが100,000または100,000,000の末尾呼び出しで実行されるようにします。
インゴ

1
@Ingo No.(1)ループが再帰として実装されていない場合、それらがスタックに現れる理由はありません(末尾呼び出し≠ジャンプ≠呼び出し)。(2)TCOは末尾再帰最適化よりも一般的です。私の答えは、として再帰を使用しています。(3)TCOに依存するスタイルでプログラミングしている場合、この最適化をオフにすることはオプションではありません。完全なTCOまたは完全なスタックトレースは言語機能であるか、そうではありません。たとえば、Schemeは、TCOの欠点と、より高度な例外システムとのバランスをとっています。
アモン

1
(1)完全に同意します。ただし、同じ理由からreturn foo(....);、方法foo(2)ですべてが指し示す数百および数千のスタックトレースエントリを保持する理由はもちろんありません。それでも、ループ、割り当て(!)、ステートメントシーケンスからの不完全なトレースを受け入れます。たとえば、変数に予期しない値が見つかった場合、それがどのようにそこに到達したかを確実に知りたいと思うでしょう。ただし、その場合にトレースが欠落していることについて文句を言うことはありません。脳に何らかの形で刻まれているのは、a)呼び出しでのみ発生するため、b)すべての呼び出しで発生するためです。どちらも意味がありません、私見。
インゴ

(3)同意しない。サイズNの問題でコードをデバッグすることが不可能になるはずの理由がわかりません。Nは通常のスタックで十分に小さくなります。次に、スイッチをオンにしてTCOをオンにします-プローブサイズの制約を効果的に削除します。
インゴ

@Ingo「同意しない。サイズNの問題でコードをデバッグすることが不可能である理由がわかりません。Nは通常のスタックで十分に小さいためです。」TCO / TCEがCPS変換の場合、それを有効にするoffはスタックをオーバーフローさせ、プログラムをクラッシュさせるため、デバッグはできません。この問題が偶発的に発生するため、GoogleはV8 JSでTCOを実装することを拒否しました。プログラマーがTCOとスタックトレースの喪失を本当に望んでいると宣言できるように、特別な構文が必要です。例外もTCOによってねじ込まれているかどうかを知っていますか?
シェルビームーアIII

6

プログラム内の呼び出しの大部分は、末尾呼び出しです。すべてのサブルーチンには最後の呼び出しがあるため、すべてのサブルーチンには少なくとも1つの末尾呼び出しがあります。テールコールにはパフォーマンス特性がありますGOTOが、サブルーチンコールの安全性があります。

適切なテールコールを使用すると、他の方法では作成できないプログラムを作成できます。たとえば、状態マシンを取り上げます。状態マシンは、各状態をサブルーチンにし、各状態遷移をサブルーチン呼び出しにすることで、非常に直接実装できます。その場合、呼び出しごとに呼び出しを行うことにより、状態から状態へ遷移し、実際には決して戻りません!適切なテールコールがなければ、すぐにスタックを破壊します。

PTCがなければ、GOTO制御フローなどのトランポリンまたは例外を使用する必要があります。それははるかにいものであり、ステートマシンの1:1の直接的な表現ではありません。

(退屈な「ループ」の例の使用をいかに巧みに回避したかに注意してください。これは、PTCがループのある言語も有用な例です。)

ここでは、TCOの代わりに「適切なテールコール」という用語を意図的に使用しました。TCOはコンパイラの最適化です。PTCは、すべてのコンパイラがTCOを実行することを必要とする言語機能です。


The vast majority of calls in a program are tail calls. 呼び出されたメソッドの「大多数」が独自の複数の呼び出しを実行する場合ではありません。 Every subroutine has a last call, so every subroutine has at least one tail call. これは、falseとして簡単に実証できますreturn a + b。(もちろん、基本的な算術演算が関数呼び出しとして定義されている非常識な言語を使用している場合を除きます。)
Mason Wheeler

1
「2つの数字を追加すると、2つの数字が追加されます。」そうでない言語を除きます。単一の算術演算子が任意の数の引数を取ることができるLisp / Schemeの+演算についてはどうですか?(+ 1 2 3)それを実装する唯一の健全な方法は関数としてです。
エヴィカトス

1
@Mason Wheeler:抽象化の反転とはどういう意味ですか?
ジョルジオ

1
@MasonWheelerそれは、間違いなく、私が今まで見た技術的な主題に関する最も手にひきつけるウィキペディアのエントリです。私はいくつかの疑わしいエントリを見てきましたが、それはただ...すごいです。
エヴィカトス

1
@MasonWheeler:On Lispの22ページと23ページのリスト長関数について話していますか?テールコールバージョンは約1.2倍複雑で、3倍近くはありません。また、抽象化の反転とはどういう意味なのかもわかりません。
マイケルショー

4

「JVMはテールコールの最適化をサポートしていないため、大量のスタックの爆発を予測しています」

これを言う人は誰でも、(1)末尾呼び出しの最適化を理解していない、(2)JVMを理解していない、または(3)両方です。

まず、Wikipediaからのテールコールの定義から始めます(Wikipediaが気に入らない場合は、代替手段があります)。

コンピューターサイエンスでは、末尾呼び出しは、最終的なアクションとして別のプロシージャ内で発生するサブルーチン呼び出しです。戻り値を生成し、呼び出し元のプロシージャによって直ちに返される場合があります

以下のコードでは、への呼び出しbar()はの末尾呼び出しですfoo()

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

テールコールの最適化は、テールコールを確認する言語実装が通常のメソッド呼び出し(スタックフレームを作成する)を使用せず、代わりにブランチを作成するときに発生します。スタックフレームにはメモリが必要であり、フレームに情報(リターンアドレスなど)をプッシュするにはCPUサイクルが必要であり、呼び出し/リターンペアは無条件ジャンプよりも多くのCPUサイクルを必要とするため、これは最適化です。

TCOは再帰によく適用されますが、それだけが使用されるわけではありません。また、すべての再帰に適用されるわけでもありません。たとえば、階乗を計算する単純な再帰的コードは、関数で最後に発生するのが乗算演算であるため、末尾呼び出しを最適化できません。

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

テールコールの最適化を実装するには、次の2つが必要です。

  • サブルーチン呼び出しに加えて分岐をサポートするプラットフォーム。
  • テールコールの最適化が可能かどうかを判断できる静的アナライザー。

それでおしまい。別の場所で述べたように、JVM(他のチューリング完全なアーキテクチャと同様)には後藤があります。たまたま無条件のgotoがありますが、機能は条件分岐を使用して簡単に実装できます。

静的分析のピースは、トリッキーなものです。1つの関数内で問題ありません。たとえば、aの値を合計する末尾再帰Scala関数はList次のとおりです。

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

この関数は次のバイトコードに変わります:

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

なお、goto 0最後に。比較すると、同等のJava関数(IteratorScalaリストを先頭と末尾に分割する動作を模倣するために使用する必要がある)は、次のバイトコードに変わります。最後の2つの操作はinvokeであり、その再帰呼び出しによって生成された値が明示的に返されることに注意してください。

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

単一の関数の末尾呼び出しの最適化は簡単です。コンパイラーは呼び出しの結果を使用するコードがないことを確認できるため、呼び出しをに置き換えることができgotoます。

人生が難しいのは、複数の方法がある場合です。JVMの分岐命令は、80x86などの汎用プロセッサーの命令とは異なり、単一のメソッドに限定されています。プライベートメソッドがある場合でも、比較的簡単です:コンパイラーは、必要に応じてそれらのメソッドを自由にインライン化できるため、テールコールを最適化できます(これがどのように機能するのか疑問に思う場合は、a switchを使用して動作を制御する一般的なメソッドを検討してください)。この手法を同じクラスの複数のパブリックメソッドに拡張することもできます。コンパイラはメソッド本体をインライン化し、パブリックブリッジメソッドを提供し、内部呼び出しはジャンプに変わります。

ただし、特にインターフェイスとクラスローダーを考慮して、異なるクラスのパブリックメソッドを検討すると、このモデルは機能しなくなります。ソースレベルのコンパイラには、テールコール最適化を実装するための十分な知識がありません。ただし、「ベアメタル」実装とは異なり、* JVM(は、これを行うための情報をHotspotコンパイラの形式で持っています(少なくとも、元Sunコンパイラが持っています)。実際に実行されるかどうかはわかりません。テールコールの最適化、および疑わないが、可能性があります。

これはあなたの質問の第2部に私を連れて行きます、私は「気にすべきか」と言い換えます。

明らかに、言語が反復の唯一のプリミティブとして再帰を使用している場合は、気になります。ただし、この機能を必要とする言語では実装できます。唯一の問題は、その言語のコンパイラが、任意のJavaクラスを呼び出して呼び出すことができるクラスを生成できるかどうかです。

そのケースの外で、私はそれが無関係であると言うことによってdownvotesを招待するつもりです。私が見た(そして多くのグラフプロジェクトで作業した)再帰コードのほとんどは、末尾呼び出しで最適化できません。単純な階乗と同様に、再帰を使用して状態を構築し、テール操作は組み合わせです。

末尾呼び出しで最適化可能なコードの場合、多くの場合、そのコードを反復可能な形式に変換するのは簡単です。例えば、そのsum()先ほど示したこと関数は次のように一般化することができますfoldLeft()sourceを見ると、実際に反復操作として実装されていることがわかります。JörgW Mittagには、関数呼び出しを介して実装されたステートマシンの例がありました。ジャンプに変換される関数呼び出しに依存しない、効率的な(および保守可能な)ステートマシンの実装が多数あります。

完全に異なるもので終わります。SICPの脚注からグーグルで検索する場合、ここで終わるかもしれません。私は個人的に私のコンパイラは置き換えることよりもはるかに興味深い場所を見つけることJSRによってJUMP


テールコールオペコードが存在する場合、テールコールの最適化では、各コールサイトで、コールを行うメソッドが後でコードを実行する必要があるかどうかを監視する以外に何が必要なのですか?場合によっては、コードを生成してスタックを操作し、ジャンプを実行するよりもreturn foo(123);インラインで実行するほうがよい場合fooがありますが、末尾呼び出しがその点。
supercat

@supercat-あなたの質問が何かわかりません。この投稿の最初のポイントは、潜在的なすべての呼び出し先のスタックフレームがどのように見えるかをコンパイラが認識できないことです(スタックフレームには、関数の引数だけでなくローカル変数も保持されることに注意してください)。互換性のあるフレームの実行時チェックを行うオペコードを追加できると思いますが、それで投稿の2番目の部分に移動します。本当の価値は何ですか?
kdgregory
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.