
この記事の続きでは、スタックされたマシンのフロントエンドが OoOを背後に持つスーパースカラープロセッサを隠すことを実証できました 。
この記事のトピックは、関数呼び出しです。
スタックと関数呼び出し
一般的な理由により、説明されているアーキテクチャでは、関数の呼び出しに問題があると想定されています。 実際、関数から戻った後、現在の関数のコンテキストでレジスタの状態を期待して返します。 最新のレジスタアーキテクチャでは、レジスタはこのために2つのカテゴリに分けられます-呼び出し側は一部の安全を担当し、呼び出し側は他の人を担当します。
しかし、このアーキテクチャでは、フロントエンドはスタックであるため、コンパイラーはレジスターの存在を認識しない場合があります。 また、プロセッサ自体がコンテキストの保存/復元を処理する必要がありますが、これは重要なタスクのようです。
しかし、最初に、スタック自体のトピックについて余談します。
スタックの概念そのものが誤解を招く可能性があります。
IBM / 360にはハードウェアスタックはありません。 ただし、関数を(再帰的にも含めて)呼び出すことができます。このため、パラメーターはメモリ領域に格納されます。これは、呼び出しの前にOSから要求する必要があります。
x86にはハードウェアスタックがありますが、このアーキテクチャをスタックに割り当てる人はいません。 このスタックは、ローカル変数と関数パラメーターを保存するための優れたメカニズムです。
AMD29K、SPARC、およびItaniumは、いわゆるバークレーリスクアーキテクチャファミリに属し、それらのスタックには別の重要な機能があります。レジスタプールは、スタックの最上部(レジスタウィンドウ)であり、関数呼び出し時のパラメータ転送を高速化することになっています。
SPARC V7は、AMD29Kよりも数年早く登場しましたが、(著者にとっては)アーキテクチャ的にスリムではないようです。
ItaniumのRSEユニットは、一般的にAMD29Kのユニットと似ていますが、後に登場しました。
AMD29Kは親切な言葉に値する
2つのハードウェアスタックがあります。 アーキテクチャのいくつかのスタックは新しいものではなく、ソ連の(そして現在の )エルブラスのバロウズB5000にありました。 ただし、2番目のスタックは、プロシージャからの戻りアドレスを格納するように設計されています。 ここでは、両方ともデータの保存に使用されます。
- メモリスタック-16を超える場合、大きなローカル変数(構造体と配列)およびパラメーターのテールを格納するために使用されます。レジスタgr125(msp)は、このスタックの最上部へのポインターです。
- レジスタスタック-スタックの最上位を形成する128個のローカルレジスタがあります
- レジスタスタックは、メモリ内のスタックの最上部にすばやくアクセスするために使用されます(もちろん、上記のメモリスタックとは異なります)
- グローバルレジスタgr126(rab)およびgr127(rfb)はスタックの最上部と最下部を定義し、gr1(rsp)は最上部へのポインターを格納します
- 1つのサイクルで2つの読み取りと1つのレコードを実行できます
- プッシュ&ポップなどの明示的なスタック操作はありません。関数が呼び出されると、コンパイラーによって定義されたレジスターの数が解放されます(呼び出しフレームがここで呼び出されるため、アクティブ化レコード)
- アクティベーションレコードからのデータへのアクセスは、各機能のlr0から番号が付けられたレジスタを経由します。
- lr0とlr1は予約されており、最初は戻りアドレス、2番目は呼び出し側関数のアクティベーションレコードです。
- 呼び出し元および呼び出された関数の登録ウィンドウは、 SPARCと同様のパラメーターと交差します
- 新しい関数を呼び出すのに十分な空きレジスタがない場合、 トラップ SPILLが発生し、そのハンドラはレジスタ値の一部をメモリにプッシュして解放します
- 逆に、空きレジスタが多すぎると、FILLがトリガーされます
- これが起こるために、コンパイラは命令を挿入します
sub gr1、gr1,16;関数プロローグ、lr0 + lr1 + 2ローカル変数 asgeu SPILL、gr1、rab;ウィンドウの上部と比較 。 。 。 ;関数本体 jmpi lr0;リターン asleu FILL、lr1、rfb;ウィンドウgr127の下部と比較
- 各関数のレジスタの番号付けは独自です。これはバークレーRISCの機能です
- ただし、スタック分割はこの特定のアーキテクチャの機能です。 SPARCでは、レジスタウィンドウは通常の(高速ではない)変数と同じスタックに保存されます。 そして、塗りつぶし/流出はブレークで行われます-各ウィンドウはそのフレームからです。
スタックを「大きいが遅い」と「小さいが速い」に分けることは非常に重要です。 動機に対処しましょう。
パラメーターの受け渡し、関数の呼び出し
スタックをローカル変数(およびパラメーター)のリポジトリとしてのアイデアは、その論理と完全性において美しいです。 弱点-システムのパフォーマンスは、レイテンシとメモリのパフォーマンスに依存します。 PDP-11の時代には何もできませんでしたが、それ以来状況は変わりました。
まず、レジスタへのアクセスはメモリへのアクセスよりも大幅に高速になり、データのキャッシュが必要になりました。 第二に、はるかに多くのレジスタを持つことが可能になりました。
多数のレジスタを所有すると、それらを使用して関数を呼び出すときに引数の転送を高速化することができます。 実際、通常、パラメーターはほとんどなく(ローカルデータよりも少ない)、その値はほとんどの場合、誰かが必要とします。 そして、どのローカルデータがレジスタに入るのに値するのかによって、最適化が決定されます。 もちろん、これは非常に粗雑な単純化であり、一般的な動機付けを示すためだけに設計されています。
現在、レジスタを介してパラメーターを渡す2つの方法が一般的です。
- 特定のレジスタに特別な役割を割り当てます。 たとえば、 MSVC(x86-64)では、最初の4つの整数引数はレジスタRCX、RDX、R8、R9を介して渡されます。 これから、すべての機能に対してレジスタの番号が統一されます。 この手法を使用するアーキテクチャには、MIPS、PPC、ARM、DEC Alphaも含まれます。コールチェーンには、スタック以外にパラメータを保存する場所がないことは明らかです。 これはすべてキャッシュに関するものです。 または、この関数の特定のパラメーターはもう使用されておらず、保存する必要がないと判断できるオプティマイザーに対して。
- 登録ウィンドウのテクニック。 このアーキテクチャのブランチは、Berkeley Riscプロジェクトから発展しました。 これには、i960、Itanium、SPARCだけでなく、すでに分解したAMD29Kも含まれます。 一番下の行は、限られた数のパラメーターと呼び出された関数のローカルデータがレジスタウィンドウにあり、次の関数が呼び出されるとウィンドウがシフトするため、このデータがスタックを形成します。 各関数には、独自のレジスタ番号があります。 ウィンドウに収まらないものはすべて通常のスタックに分類され、一時データ用のグローバルレジスタも使用できます。 したがって、 i960およびSPARCの場合、レジスタスタックは通常のスタックに散在していますが、AMD29KおよびItaniumの場合、これらは異なるスタックです。 実際、AMD29KとItaniumは、コンパイラが「高速スタック」にふさわしいと考えるデータの種類を選択するためのコンパイラを提供します。他のすべては自動的に行われます。 これは、Cで廃止されたキーワード「レジスタ」を連想させるものですが、決定はコンパイラによって行われますが、高レベル言語です。
潜在的なパフォーマンスの観点では、両方のアプローチはほぼ同等です。 最初のアプローチでは、最適化の全体的な負担はコンパイラにあり、プロセッサにはありません。これにより、(おそらく)最終システムの設計がより簡単で安価になります。
しかし、少し夢中になったので、設計中のアーキテクチャで現在の関数のコンテキストを維持することに戻りましょう。
関数コンテキストの保存
そして、このコンテキストには何が含まれていますか? 子プロシージャの呼び出し時に占有されていたレジスタ。
この場合、計算が不十分な式のレジスタはトポロジカルソートを介して相互接続されますが、これは呼び出された関数には関係ありません。 モップによる出力レジスターのキャプチャー時に、キャプチャーされた順番は関係ありません。
注目に値するニュアンスがあります-関数呼び出しの開始前に、その引数が計算されたすべてのモップがうまくいくはずです。 したがって、プロセッサの観点から見ると、関数呼び出しは、任意の数の引数を持つ一般化された命令です。
ここで、レジスタの番号付けを決定する価値があります。
番号付けを一般的にする、つまり SPARCではなくMIPSパスをたどりました。
- 呼び出しチェーンが十分に長い場合、 すべてのレジスタがビジーであることは明らかです。 そして、どれをメモリにアンロードするか(SPILL)について話します。 つまり キャプチャ順序は依然として重要です。
- キャプチャ順序は通話履歴に依存します
- 関数内では、動的に定義されます
- 特定のレジスタで関数の結果を返すという保証はありませんが、(FILLの逆実行中に)このレジスタと競合しない
- 著者はそのような競合を回避する方法を見ていません。もちろん、それは実際には何も存在しないという意味ではありません
ウィンドウを登録してみましょう。
- 各関数のレジスタの番号付けが新たに始まり、これは素晴らしいことです。 バックグラウンドを考慮することから私たちを救います
- 従来技術-循環レジスタバッファ、FILL&SPILL
- 2つの物理スタックを使用します-レジスタウィンドウとその他すべてに
- 関数が呼び出されたときに使用されたレジスタを覚えておく必要があります そして、私たちはそのような情報を持っています。 レジスタr0、r5、r11がビジーであるとします。 実際、レジスタの使用範囲の4分の1しか使用されておらず、何らかの方法でそれらを「パック」する誘惑があります。 しかし、その後、子関数から戻るとき、それらを「アンパック」する必要があります。 したがって、現在の関数(この場合)のレジスターのプールは、12個のレジスターのサイズのままです(+サービス情報:戻りアドレス、前のフレーム)。 さらに、レジスタ自体の数はそれほど重要ではなく、同時読み取り/書き込み操作の数ははるかに高価ですが、変更されません
- しかし、レジスタをメモリに保存することで、おそらく何かを行うことができます。明らかに不要なデータを未使用のレジスタからメモリに保存しないようにしましょう
- これを行うには、ビジーな関数レジスタを書き込んだ後、雇用のマスクを保存します
- このため、順番に、必要なレジスタの数ではなく、フレームごとにFILL&SPILLを実行する必要があります。一度に1つの呼び出しに関係するすべて
- 循環レジスタバッファの現在の先頭には、常にフレーム記述子があります(SPILLの次の候補)
- FILLで取得する最初のレジスタには、使用済みレジスタのマスク(またはその一部)が含まれます。これを使用して、必要に応じて、必要な数のレジスタをメモリから取得します
ただし、関数呼び出しの外側に注目することで、これらすべてが内部で実行される方法を見失っています。 著者は自分自身をハードウェアの専門家とは考えていませんが、それでも問題を予測しています。
幸いなことに、 次の記事でそれらに対処します。