web-dev-qa-db-ja.com

ネストされた関数呼び出しをインライン化できるのに、なぜプログラムは呼び出しスタックを使用するのですか?

コンパイラーに次のようなプログラムを実行させないのはなぜですか。

function a(b) { return b^2 };
function c(b) { return a(b) + 5 };

次のようなプログラムに変換します。

function c(b) { return b^2 + 5 };

それによって、コンピュータがc(b)の戻りアドレスを覚えておく必要がなくなりますか?

増加したハードディスク領域とRAMプログラムを格納してそのコンパイルをサポートするために必要な)が、コールスタックを使用する理由であると思います。それは正しいですか?

33
moonman239

これは「インライン化」と呼ばれ、多くのコンパイラーは、それが理にかなっている場合の最適化戦略としてこれを行います。

特定の例では、この最適化により、スペースと実行時間の両方が節約されます。しかし、関数がプログラム内の複数の場所で呼び出された場合(珍しいことではありません!)、コードサイズが大きくなるため、戦略はより疑わしくなります。 (もちろん、関数がそれ自体を直接的または間接的に呼び出すと、コードのサイズが無限になるため、インライン化することが不可能になります。)

そして、明らかにそれは「プライベート」機能に対してのみ可能です。外部の呼び出し元に公開される関数は、少なくとも動的リンクを使用する言語では最適化できません。

75
JacquesB

質問には2つの部分があります:なぜ(関数呼び出しをその定義で置き換えるのではなく)複数の関数があるのか​​、そしてそれらの関数を、データを他の場所に静的に割り当てる代わりにコールスタックで実装するのですか?

最初の理由は再帰です。 「このリストのすべての項目に対して新しい関数呼び出しを作成しましょう」という種類だけでなく、他の多くの関数を間に挟んで、同時に2つの関数呼び出しがアクティブになるような控えめな種類もあります。これをサポートするには、ローカル変数をスタックに配置する必要があり、一般的に再帰関数をインライン化することはできません。

次に、ライブラリに問題があります。どの関数がどこからどのくらいの頻度で呼び出されるかわからないため、「ライブラリ」を実際にコンパイルすることはできず、便利な高レベルの形式ですべてのクライアントに出荷されるだけです。アプリケーションにインライン化。これに関する他の問題は別として、動的リンクを完全に失い、そのすべての利点があります。

さらに、次のことができる場合でも、関数をインライン化しない多くの理由があります。

  1. 必ずしも高速であるとは限りません。スタックフレームを設定して破棄することは、実行時間の0.1%にも及ばない多くの大きな関数やループ関数の場合、1ダースの単一サイクル命令になる可能性があります。
  2. 遅くなるかもしれません。コードの複製にはコストがかかります。たとえば、命令キャッシュにより多くの圧力がかかります。
  3. 一部の関数は非常に大きく、多くの場所から呼び出され、どこにでもインライン化することで、合理的な値をはるかに超えてバイナリが増加します。
  4. 多くの場合、コンパイラーは非常に大きな関数で苦労します。他のすべてが等しい場合、サイズ2 * Nの関数は2 * T時間以上かかりますが、サイズNの関数はT時間かかります。
51
user7043

スタックを使用すると、有限数のレジスターによって課される制限をエレガントにバイパスできます。

正確に26個のグローバル "レジスタa〜z"(または8080チップの7バイトサイズのレジスタのみを持つ)を想像してください。このアプリで作成するすべての関数がこのフラットリストを共有します。

最初の数個のレジスタを最初の関数に割り当て、それが3つしかかからないことを知っていて、2番目の関数の "d"から始めれば、素朴なスタートになります...すぐに足りなくなります。

代わりに、チューリングマシンのように比喩的なテープがある場合、各関数にすべての変数を保存することで「別の関数を呼び出す」ように開始させることができますsingおよびテープをforward()し、次に呼び出し先の関数は、必要な数のレジスターと混同できます。呼び出し先が終了すると、必要に応じて呼び出し先の出力を取得する場所を知っている親関数に制御を戻し、テープを逆方向に再生してその状態を復元します。

あなたの基本的なコールフレームはまさにそれであり、コンパイラーがある関数から別の関数への遷移の周りに置く標準化されたマシンコードシーケンスによって作成され、削除されます。 (Cスタックフレームを覚える必要があったので久しぶりですが、X86_calling_conventions。)

(再帰は素晴らしいですが、スタックなしでレジスタを操作しなければならなかった場合は、reallyスタックに感謝します。)


増加したハードディスク領域とRAMプログラムを格納してそのコンパイルをサポートするために必要な)が、コールスタックを使用する理由であると思います。それは正しいですか?

最近はもっとインライン化できますが(「より高速」は常に良いです。「より少ないkbのアセンブリ」は、ビデオストリームの世界ではほとんど意味がありません)主な制限は、特定のタイプのコードパターン全体でフラット化するコンパイラの機能にあります。

たとえば、ポリモーフィックオブジェクト-渡すオブジェクトの唯一のタイプがわからない場合は、フラット化できません。オブジェクトの機能のvtableを見て、そのポインターを介して呼び出す必要があります...実行時に行うのは簡単で、コンパイル時にインライン化することは不可能です。

最新のツールチェーンは、多態的に定義された関数を十分に平坦化して、呼び出し元を十分にフラット化すると、喜んでインライン化できますexactly objのフレーバーは次のとおりです。

class Base {
    public: void act() = 0;
};
class Child1: public Base {
    public: void act() {};
};
void ActOn(Base* something) {
    something->act();
}
void InlineMe() {
    Child1 thingamabob;
    ActOn(&thingamabob);
}

上記では、コンパイラーは、act()の内部を介してInlineMeから静的にインライン化することも、実行時にvtableに触れる必要もないことを選択できます。

ただし、同じ関数の他の呼び出しareがインライン化されている場合でも、オブジェクトのフレーバーに不確実性があると、オブジェクトは個別の関数の呼び出しとして残ります。

16
xander

そのアプローチが処理できない場合:

function fib(a) { if(a>2) return fib(a-1)+fib(a-2); else return 1; }

function many(a) { for(i = 1 to a) { b(i); };}

そこにはコールスタックが制限されているか、まったくない言語とプラットフォームがありますPICマイクロプロセッサには、2〜32エントリに制限されたハードウェアスタックがあります 。これにより、設計上の制約が生じます。

COBOLは再帰を禁止します: https://stackoverflow.com/questions/27806812/in-cobol-is-it-possible-to-recursively-call-a-paragraph

再帰を禁止するということは、プログラムのコールグラフ全体をDAGとして静的に表現できることを意味します。コンパイラーは、リターンの代わりに固定ジャンプで呼び出された場所ごとに、関数のコピーを1つ発行できます。スタックは必要ありません。プログラムスペースが増えるだけで、複雑なシステムではかなりの量になる可能性があります。しかし、小さな組み込みシステムの場合、これは、実行時にスタックオーバーフローが発生しないことを保証できることを意味します。これは、原子炉/ジェットタービン/自動車のスロットル制御などにとっては悪いニュースです。

11
pjc50

関数 インライン化 が必要であり、ほとんどの( 最適化 )コンパイラがそれを行っています。

インライン化では、呼び出された関数が既知である必要があります(呼び出された関数が大きすぎない場合にのみ有効です)。これは、概念的には、呼び出された関数の書き換えによって呼び出しを置き換えているためです。そのため、一般に、不明な関数(たとえば、関数ポインタ-および 動的リンク共有ライブラリ -からの関数をインライン化することはできません。これは、一部の vtable ;ただし、コンパイラによっては devirtualization テクニックを介して最適化される場合があります)。もちろん、再帰関数をインライン化することが常に可能であるとは限りません(一部の賢いコンパイラーは 部分評価 を使用し、一部のケースでは再帰関数をインライン化できます)。

インライン化は、簡単に可能な場合でも常に効果的であるとは限らないことにも注意してください。あなた(実際にはコンパイラ)がコードサイズを大きくして、 CPUキャッシュ (または 分岐予測子 )になる可能性があります。効率が悪くなり、プログラムの実行が遅くなります。

Qestionにタグを付けたので、私は 関数型プログラミング スタイルに少し焦点を合わせています。

コールスタックを必要としないことに注意してください(少なくとも「コールスタック」式のマシンの意味で)。ヒープのみを使用できます。

したがって、継続を見て、 継続渡しスタイル (CPS)および CPS変換 (直感的には、継続 クロージャ をヒープに割り当てられた具体化された「コールフレーム」として使用でき、コールスタックを模倣しているため、効率的な ガベージコレクタ )が必要です。

Andrew Appelが本を書きました 継続を伴うコンパイル と古い論文 ガベージコレクションはスタック割り当てよりも高速です 。 A.Kennedyの論文(ICFP2007)も参照してください 継続によるコンパイル、続き

また、継続と編集に関連するいくつかの章があるQueinnecの LISP In Small Pieces の本を読むことをお勧めします。

一部の言語(例: Brainfuck )または抽象マシン(例: [〜#〜] oisc [〜#〜][〜#〜] ram [〜# 〜] )呼び出し機能はありませんが、それでも チューリング完全 であるため、非常に便利であっても、(理論的には)関数呼び出しメカニズムは必要ありません。ところで、一部の古い 命令セット アーキテクチャ(例: IBM/37 )には、ハードウェアコールスタックも、プッシュコールマシン命令もありません(IBM/370には分岐とリンク機械語命令)

最後に、プログラム全体(必要なすべてのライブラリを含む)に再帰がない場合は、各関数の戻りアドレス(および実際には静的になっている「ローカル」変数)を静的な場所に格納できます。古い Fortran77 コンパイラは、1980年代初頭にそれを行いました(そのため、コンパイルされたプログラムは、その時点でコールスタックを使用しませんでした)。

インライン化(​​関数呼び出しを同等の機能で置き換える)は、小さな単純な関数の最適化戦略としてうまく機能します。関数呼び出しのオーバーヘッドは、追加されたプログラムサイズの小さなペナルティ(または場合によっては、まったくペナルティがない)と効果的にトレードオフできます。

ただし、他の関数を呼び出す大きな関数は、すべてがインライン化されている場合、プログラムのサイズが非常に大きくなる可能性があります。

呼び出し可能な関数の要点は、プログラマーだけでなく、マシン自体による効率的な再利用を促進することであり、それには合理的なメモリやディスク上のフットプリントなどのプロパティが含まれます。

価値があることについては、呼び出しスタックなしで呼び出し可能な関数を使用できます。例:IBM System/360。そのハードウェアでFORTRANなどの言語でプログラミングする場合、プログラムカウンター(戻りアドレス)は、関数のエントリポイントの直前に予約されたメモリの小さなセクションに保存されます。再利用可能な関数は許可されますが、再帰コードやマルチスレッドコードは許可されません(再帰呼び出しまたは再入可能呼び出しを試みると、以前に保存された戻りアドレスが上書きされます)。

他の回答で説明されているように、スタックは良いものです。再帰呼び出しとマルチスレッド呼び出しを容易にします。再帰を使用するようにコーディングされたアルゴリズムは、再帰に依存せずにコーディングできますが、結果はより複雑になり、維持が難しくなり、効率が低下する可能性があります。スタックレスアーキテクチャがマルチスレッドをサポートできるかどうかはわかりません。

8
Zenilogix