ループが再帰よりも速いのはなぜですか?


17

実際には、再帰はループとして書くことができ(逆も同様です)、実際のコンピューターで測定すると、同じ問題に対してループが再帰よりも速いことがわかります。しかし、この違いをもたらす理論はありますか、それとも主に経験的ですか?


9
見た目は、実装が不十分な言語では再帰よりも高速です。適切なTail Recursionを使用した言語では、再帰プログラムを舞台裏でループに変換できます。この場合、同一であるため、違いはありません。
jmite

3
はい。サポートしている言語を使用する場合、パフォーマンスに悪影響を与えることなく(末尾)再帰を使用できます。
jmite

1
@jmite、実際にループに最適化できるテール再帰は非常にまれであり、あなたが思っているよりもはるかにまれです。特に、参照カウント変数のようなマネージ型を持つ言語では。
ヨハン-モニカを

1
タグの時間の複雑さを含めたので、ループのあるアルゴリズムは再帰のあるアルゴリズムと同じ時間の複雑さを追加する必要があると思いますが、後者の場合、所要時間は、再帰のオーバーヘッドの量。
Lieuwe Vinkhuijzen 16年

2
ねえ、あなたは多くの良い答えで賞金を追加し、ほとんどすべての可能性を使い果たしたので、何か必要なものがありますか、何かを明確にする必要があると感じていますか?追加するものはあまりありません。回答を編集したり、コメントを残したりすることができますので、これは一般的な質問です(個人的な質問ではありません)。

回答:


17

ループが再帰よりも速い理由は簡単です。
アセンブリでは、ループは次のようになります。

mov loopcounter,i
dowork:/do work
dec loopcounter
jmp_if_not_zero dowork

単一の条件付きジャンプとループカウンターの簿記。

再帰(コンパイラによって最適化されていない、または最適化できない場合)は次のようになります。

start_subroutine:
pop parameter1
pop parameter2
dowork://dowork
test something
jmp_if_true done
push parameter1
push parameter2
call start_subroutine
done:ret

はるかに複雑で、少なくとも3回のジャンプ(完了したかどうかを確認するための1回のテスト、1回の呼び出し、1回の戻り)が発生します。
また、再帰では、パラメータを設定して取得する必要があります。
すべてのパラメーターが既に設定されているため、ループではこのようなものは必要ありません。

理論的には、パラメーターも再帰的に適切な位置にとどまりますが、私が知っているコンパイラーは、実際にはその最適化を行っていません。

呼び出しとjmpの違い呼び出しと
戻りのペアは、jmpほど高価ではありません。ペアは2サイクルかかり、jmpは1サイクルかかります。目立たない。
レジスタパラメーターをサポートする呼び出し規約では、CPUのバッファーがオーバーフローしない限り、パラメーターのオーバーヘッドは最小限に抑えられますが、スタックパラメーターでも安価です。
再帰を遅くするのは、呼び出し規約と使用中のパラメーター処理によって決定される呼び出しセットアップのオーバーヘッドです。
これは実装に大きく依存します。

不十分な再帰処理の 例たとえば、参照カウントされたパラメーター(非const管理型パラメーターなど)が渡されると、参照カウントのロック調整を行う100サイクルが追加され、ループに対してパフォーマンスが完全に低下します。
再帰にチューニングされた言語では、この悪い動作は発生しません。

CPUの最適化
再帰が遅いもう1つの理由は、CPUの最適化メカニズムに対して動作することです。
戻り値は、行にあまり多くない場合にのみ正しく予測できます。CPUには、(少数の)少数のエントリを持つリターンスタックバッファがあります。それらがなくなると、すべての追加のリターンが予測不能になり、大きな遅延が発生します。
スタックを使用するCPUでは、バッファサイズを超えるバッファコールベースの再帰呼び出しを避けることが最善です。

再帰
を使用した些細なコード例についてフィボナッチ数生成のような些細な再帰の例を使用すると、これらの効果は発生しません。します。
これらの簡単な例を適切に最適化しない環境で実行すると、コールスタックが(不必要に)範囲を超えて成長します。

末尾再帰について
コンパイラは、末尾再帰をループに変更することで末尾再帰を最適化する場合があることに注意してください。この点で既知の良好な実績がある言語でのみ、この動作に依存するのが最善です。
多くの言語では、最後のリターンの前に隠されたクリーンアップコードが挿入され、末尾再帰の最適化が妨げられます。

真の再帰と擬似再帰の混乱
プログラミング環境が再帰的なソースコードをループに変えている場合、実行されているのは間違いなく真の再帰ではありません。
真の再帰では、パンくずリストを保存する必要があるため、再帰ルーチンは、終了後にステップをトレースバックできます。
ループを使用するよりも再帰を遅くするのは、このトレイルの処理です。この効果は、上記で概説した現在のCPU実装によって拡大されます。

プログラミング環境の効果
言語が再帰の最適化に向けて調整されている場合は、あらゆる手段で再帰を使用してください。ほとんどの場合、言語は再帰を何らかのループに変換します。
それが不可能な場合、プログラマーも同様に強いられます。プログラミング言語が再帰に向けて調整されていない場合、ドメインが再帰に向いていない限り、それを避ける必要があります。
残念ながら、多くの言語は再帰をうまく処理しません。

再帰の誤用再帰
を使用してフィボナッチ数列を計算する必要はありません。実際、これは病理学的例です。
再帰は、明示的にそれをサポートする言語で、またはツリーに格納されたデータの処理のように、再帰が輝くドメインで最もよく使用されます。

再帰はループとして記述できることを理解しています

はい、カートを馬の前に置いても構いません。
再帰のすべてのインスタンスはループとして記述できますが、それらのインスタンスの一部では、ストレージのような明示的なスタックを使用する必要があります。
再帰的なコードをループに変えるためだけに独自のスタックをロールする必要がある場合は、単純な再帰を使用することもできます。
もちろん、ツリー構造で列挙子を使用するなどの特別なニーズがあり、適切な言語サポートがない場合を除きます。


16

これらの他の答えはやや誤解を招く恐れがあります。私は、彼らがこの格差を説明できる実装の詳細を述べていることに同意しますが、彼らはこの事例を誇張しています。jmiteが正しく示唆しているように、それらは関数呼び出し/再帰の壊れた実装に向け実装指向です。多くの言語は再帰を介してループを実装しているため、これらの言語ではループが明らかに高速になることはありません。理論的には、再帰はループ(両方が適用される場合)よりも効率的ではありません。Guy Steeleの1977年の論文「高価な手続き呼び出し」の神話、または有害と考えられる手続き実装、またはLambda:the Ultimate GOTOの要約を引用してみましょう。

フォークロアでは、GOTOステートメントは「安価」であり、プロシージャコールは「高価」であると述べています。この神話は、主に不十分に設計された言語実装の結果です。この神話の歴史的な成長が考慮されます。この神話を覆す理論的なアイデアと既存の実装の両方について説明します。プロシージャコールを無制限に使用すると、文体の自由度が高くなることが示されています。特に、追加の変数を導入することなく、フローチャートを「構造化」プログラムとして作成できます。GOTOステートメントとプロシージャコールの難しさは、抽象プログラミングの概念と具体的な言語構造の矛盾として特徴付けられます。

「抽象的プログラミングの概念と具体的な言語構造間の競合は、」ほとんどの理論モデルは、例えば、型指定されていないという事実から分かるようにラムダ計算はスタックを持っていません。もちろん、上記の論文で説明されているように、またHaskellなどの再帰以外の反復メカニズムを持たない言語でも実証されているように、この競合は必要ありません。

実演させてください。簡単にするために、数値とブール値を使用した「適用された」ラムダ計算を使用し、固定小数点コンビネーターがあると仮定します。fixfix f x = f (fix f) xλバツMNM[N/バツ][N/バツ]バツMN

次に例を示します。factとして定義

fact = fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 1

の評価は次のとおりですfact 3。ここでは、簡潔にするgためにfix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1))、つまりの同義語として使用しますfact = g 1。これは私の議論に影響しません。

fact 3 
~> g 1 3
~> fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 1 3 
~> (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) g 1 3
~> (λa.λn.if n == 0 then a else g (a*n) (n-1)) 1 3
~> (λn.if n == 0 then 1 else g (1*n) (n-1)) 3
~> if 3 == 0 then 1 else g (1*3) (3-1)
~> g (1*3) (3-1)
~> g 3 2
~> fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 3 2
~> (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) g 3 2
~> (λa.λn.if n == 0 then a else g (a*n) (n-1)) 3 2
~> (λn.if n == 0 then 3 else g (3*n) (n-1)) 2
~> if 2 == 0 then 3 else g (3*2) (2-1)
~> g (3*2) (2-1)
~> g 6 1
~> fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 6 1
~> (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) g 6 1
~> (λa.λn.if n == 0 then a else g (a*n) (n-1)) 6 1
~> (λn.if n == 0 then 6 else g (6*n) (n-1)) 1
~> if 1 == 0 then 6 else g (6*1) (1-1)
~> g (6*1) (1-1)
~> g 6 0
~> fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 6 0
~> (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) g 6 0
~> (λa.λn.if n == 0 then a else g (a*n) (n-1)) 6 0
~> (λn.if n == 0 then 6 else g (6*n) (n-1)) 0
~> if 0 == 0 then 6 else g (6*0) (0-1)
~> 6

詳細を見なくても形状から、成長はなく、各反復には同じ量のスペースが必要であることがわかります。(技術的には、数値結果が大きくなりますが、これは避けられず、whileループにも当てはまります。)ここで無限に成長する「スタック」を指摘することはできません。

ラムダ計算の典型的なセマンティクスは、一般に「テールコール最適化」と誤称されていることをすでに実行しているようです。もちろん、ここでは「最適化」は行われていません。「通常の」コールとは対照的に、「テール」のコールには特別なルールはありません。このため、関数呼び出しのセマンティクスの多くの抽象的な特性評価では、テールコールの「最適化」を行うことは何もないので、テールコールの「最適化」が行うことの「抽象的な」特性を与えることは困難です。

fact多くの言語での「スタックオーバーフロー」の類似の定義は、これらの言語による関数呼び出しセマンティクスの適切な実装の失敗です。(一部の言語には言い訳があります。)状況は、リンクリストを使用して配列を実装する言語実装を持つことにほぼ類似しています。そのような「配列」へのインデックス付けは、配列の期待を満たさないO(n)操作になります。リンクリストの代わりに実際の配列を使用する言語の別の実装を作成した場合、「配列アクセス最適化」を実装したとは言わず、壊れた配列の実装を修正したと言います。

だから、Veedracの答えに応える。 スタックは再帰の「基本」ではありません。評価の過程で「スタックのような」動作が発生する限り、これはループ(補助データ構造なし)がそもそも適用できない場合にのみ発生します!別の言い方をすれば、まったく同じパフォーマンス特性を持つ再帰を備えたループを実装できます。実際、SchemeとSMLはどちらもループ構造を含んでいますが、どちらも再帰に関して定義しています(そして、少なくともSchemeでは、再帰呼び出しに展開されるマクロとして実装されるdoことがよくあります)。同様に、Johanの答えについては、コンパイラは、再帰用に記述されたJohanアセンブリを出力する必要があります。確かに、ループを使用しても再帰を使用しても、まったく同じアセンブリ。コンパイラーが(ある程度)義務常にとにかくループでは表現できない何かをしているとき、Johanが説明するようなアセンブリを出力します。Steeleの論文で概説されているように、Haskell、Scheme、SMLなどの言語の実際の実践で実証されているように、テールコールを「最適化」できるのは「非常にまれ」ではなく、「最適化」できるからです。再帰の特定の使用が一定のスペースで実行されるかどうかは、その記述方法によって異なりますが、それを可能にするために適用する必要がある制限は、ループの形状に問題を適合させるために必要な制限です。(実際には、それらはそれほど厳密ではありません。補助変数を必要とするループとは対照的に、テールコールを介してよりクリーンかつ効率的に処理される状態マシンのエンコードなどの問題があります。)とにかくコードがループではない場合は、より多くの作業を行います。

私の推測では、Johanはテールコールの「最適化」を実行するタイミングにrestrictions意的な制限があるCコンパイラを参照しているようです。また、Johanは「マネージ型の言語」について話すときに、おそらくC ++やRustなどの言語について言及しているでしょう。C ++ のRAIIイディオムは、Rustにも存在し、表面的にはテールコールではなくテールコールのように見えます(「デストラクタ」を呼び出す必要があるため)。別の構文を使用してテール再帰を許可するわずかに異なるセマンティクスにオプトインする提案がありました(つまり、デストラクタを呼び出す前に最後の末尾呼び出しであり、明らかに「破壊された」オブジェクトへのアクセスを禁止します。(ガベージコレクションにはこのような問題はなく、Haskell、SML、およびSchemeはすべてガベージコレクションされた言語です。)Smalltalkなどのいくつかの言語は、これらの中で「スタック」をファーストクラスオブジェクトとして公開します。 「スタック」はもはや実装の詳細ではありませんが、これは異なるセマンティクスを持つ別個のタイプの呼び出しを持つことを排除しません。(Javaは、セキュリティのいくつかの側面を処理する方法のためにできないと言っていますが、これは実際にはfalseです。

実際には、壊れた関数呼び出しの実装の普及は、3つの主な要因に起因しています。まず、多くの言語は実装言語(通常はC)から壊れた実装を継承します。第二に、決定論的なリソース管理は素晴らしく、問題をより複雑にしますが、これを提供する言語はほんの一握りです。第三に、私の経験では、ほとんどの人が気にする理由は、デバッグ目的でエラーが発生したときにスタックトレースが必要なことです。2番目の理由のみが、潜在的に理論的に動機付けられる理由です。


私は「基本的」を使用して、主張が真実であるという最も基本的な理由を参照しました。論理的にこの方法である必要があるかどうかではありません(明らかにそうではありません。しかし、私は全体としてあなたのコメントに同意しません。ラムダ計算を使用しても、スタックが不明瞭になるほど削除されません。
Veedrac

あなたの主張「コンパイラが(多少)ヨハンが説明するようなアセンブリを(ある程度)義務付けられるのは、ループで表現できない何かをしているときだけです。」また、非常に奇妙です。コンパイラは(通常)同じ出力を生成するコードを生成できるため、コメントは基本的にトートロジーです。しかし実際には、コンパイラーは同等のプログラムごとに異なるコードを生成するため、問題はその理由に関するものでした。
Veedrac

O1

類推すると、なぜループに不変文字列を追加するのに「必要ない」と二次時間がかかるのかという質問に答えることは完全に合理的ですが、実装がこのように壊れたと主張することはそうではありません。
Veedrac

非常に興味深い答えです。それは少し暴言のように聞こえますが:-)。何か新しいことを学んだので、賛成しました。
ヨハン-モニカを

2

基本的に違いは、再帰にはおそらく不要な補助データ構造であるスタックが含まれますが、ループでは自動的に行われないことです。まれにしか、典型的なコンパイラーは、結局スタックを実際に必要としないと推測できます。

代わりに、割り当てられたスタックで手動で動作するループを比較する場合(たとえば、ヒープメモリへのポインタを使用)、ハードウェアスタックを使用するよりも速くも遅くさえありません。

弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.