レジスタ割り当てとは
変数(すなわち無限個の仮想レジスタ)の処理を有限個の実レジスタに落とし込む処理である。変数を極力レジスタに割り当て、どうしてもレジスタが足りないときは変数をスタックに割り当てて退避し、レジスタを空ける。
「概説」の要約
「概説」の説明は有用な情報を含んでいるがそのままではまとまりに欠けるので、ここで整理要約させて頂く。
- 関数呼び出し規約: 引数はレジスタに順に載せる。戻り値は第一レジスタ。これら関数の出入口の処理は
RegAlloc.h
で実装。
- 式(関数本体、メインルーチン)のレジスタ割り当て:
RegAlloc.g
。変数からレジスタへの写像regenv
と命令列を受け取りレジスタ割り当てを行った命令列と新しいregenv
をデータ型NoSpill
で返す。
- どのレジスタに変数を割り当てるか
- 空いているレジスタ (生きている変数の入っていないレジスタ)
SparcAsm.fv
: まだ生きている変数を返す
(現在の式と、これから後にくる命令列cont
を調べる)
- その変数が次に使われるときに置かれるレジスタをなるべく選び、将来の無駄なmovを削減する (
RegAlloc.target
) (register targetingまたはregister coalescing)
- 関数呼び出しは全てのレジスタを破壊するのでそれより先は調べても無意味、戻り値にはそれを示す真偽値を含む
- 空きがなかったら→変数を退避してレジスタを空ける(レジスタ溢れ=register spilling)
- どのレジスタを空けるか←言及なし
- 関数呼び出しでも退避が必要 (レジスタは破壊される)→全ての生きている変数を退避
- レジスタが溢れたら
RegAlloc.g
は溢れの発生を示すToSpill
を返す
- 変数をなるべく早く退避する→変数の定義時点で退避(
Save
命令を挿入)
- 退避したら「生きている変数」から除外→
Forget
命令を挿入
- 退避直後からレジスタ割り当てをやり直す
- 退避した変数を再び参照するとき
regenv
に見つからない→RegAlloc.g'
で例外発生→RegAlloc.g'_and_restore
で処理(レジスタへ変数を復帰するRestore
命令を挿入)
命令列と仮想命令
レジスタ割り当てでは仮想命令と命令列が重要な役割を担うので、まずそれを整理する。
命令列は主にLet
ノードで構成されるリストであり、末尾はAns
である。Let
とAns
はそれぞれ命令を一つ指す。
これらの命令には入力オペランドの情報はあるが、出力オペランドはその命令を持つLet
が定義する変数。Ans
ノードの場合は、レジスタ割り当てのためには出力オペランドが外から指定されなくてはならない。全ての命令列はAns
で終わるから、命令列のレジスタ割り当ては最後に結果を返すレジスタか変数を最初に指定するということになる。
命令列を構成するノードにはもう一種類、Forget
というノードがある。これは特殊な指示ノードで、レジスタ割り当ての途中で必要に応じて挿入される。
レジスタ割り当てに直接関わる仮想命令は二つある。
Save(r, x)
は変数x
をレジスタr
からスタック上に退避する命令である。
Restore(x)
はその退避された変数x
をレジスタにロードする仮想命令である。
処理階層
regAlloc.mlの中心となる部分は次のように処理単位の大きさによって関数が分かれ、上位のものがより下位の関数を呼んで処理を行う。
f
- プログラム全体のレジスタ割り当て。関数をそれぞれ
h
で変換、メインの式をg_repeat
で変換して、レジスタ割り当てされた新しいプログラムを返す。
h
- トップレベル関数のレジスタ割り当て。変数(クロージャと引数)とレジスタの対応を
regenv
に登録し、関数本体をg_repeat
で変換。
g_repeat
- ひとまとまりの式(メインの式や関数本体)のレジスタ割り当て。実際にレジスタ割り当てするために
g
を呼ぶ。
g
- 命令列のレジスタ割り当て。命令列中のノード一つを扱うと共に、後続命令列を処理するため自分自身を再帰的に呼ぶ。ノードが
Let
の場合は次のような処理をする。
- ノードが指す命令を
g'_and_restore
で割り当てる
- 命令の出力先レジスタを割り当てる
g
自身を再帰的に呼んで後続命令列のレジスタ割り当てを行う
g'_and_restore
g'
をラップする。
g'
- 一命令のレジスタ割り当てを行う。命令が条件分岐や関数呼び出しの場合は
g'_if
やg'_call
が担当する。
処理の流れ
レジスタ割り当ての中心は関数g
である。おおよそ命令列のノード一つにつきg
の呼び出しが一回ネストする。処理した命令列の長さに等しい(もしくはそれ以上の)深さで再帰呼び出しすることになる。
g
以下の関数は、割り当てに成功すると戻り値としてNoSpill(レジスタ割り当て後の命令列, 命令列直後のregenv
)
を返す。失敗するとToSpill(割り当てをやり直す命令列, レジスタから溢れた変数のリスト)
を返す。
簡単に説明するとそうなるが、レジスタから変数を溢れが発生すれば割り当てをやり直すためにToSpill
を返し、一度も発生しなければNoSpill
を返す、と表現する方が正鵠を射ている。溢れが発生しなかったということが結果としてレジスタ割り当ての完了も意味することになる。
ToSpill
によってバックトラックが駆動される。命令列のどこかでレジスタから変数が溢れてToSpill
を返されると、どんどんToSpill
を返して巻き戻る。どこまで戻るかというと、レジスタから溢れた変数が定義された時点までである。
そこまで戻ると、変数を退避するSave
命令をLet
の直後に挿入し、再びレジスタ割り当てをやり直す。
挿入されたSave
命令自身はレジスタ割り当てに影響しない。では何故やり直しで結果が変わるのかというと、ToSpill
を返すときにForget
が命令列に挿入されるからである。
参照した変数がレジスタに載っていなかったら
階層の一番下、命令一つを処理するのがg'
である。
g'
ではオペランドを変数から対応するレジスタに置き換えた命令を返す。
無条件にNoSpill
を返すように見えるが、置き換えのときに変数がレジスタに載っていなければfind
の中でNoReg
エラーが発生する。
g'_and_restore
はこれに対処するための仕掛けである。NoReg
エラーが発生すると、参照する変数をレジスタに復帰させる為のRestore
命令を直前に挿入した命令列をでっち上げ、g
を呼んでレジスタ割り当てさせる。
これによって、Restore
命令でどのレジスタにロードするかという問題に帰着する。
g
におけるLet
の処理 前編
ここがレジスタ割り当ての中心であるので詳しく扱う。
前述の通り、ここでの処理は次の三ステップである。
- ノードが指す命令を
g'_and_restore
で割り当てる。
- 命令の出力先レジスタを割り当てる。
g
自身を再帰的に呼んで後続命令列のレジスタ割り当てを行う。
既に見たようにステップ1は別の問題への変換で決着した。次のステップ2が、regAlloc.ml中で唯一実際にレジスタを割り当てる箇所である。
レジスタ割り当ては関数alloc
を呼んで行う。この関数は空きレジスタr
があればAlloc(r)
を返し、なければレジスタから溢れさせる変数y
を選んでSpill(y)
返す。
結果がSpill
ならばレジスタ溢れが発生したことを示すToSpill
を返して後戻りを開始する。
Alloc
が返された場合はステップ3の後続命令列のレジスタ割り当てに進むので、後続命令列についてg
を呼び出す。NoSpill
で結果が得られた場合は、自分が変換した命令と合わせてレジスタ割り当て完了した命令列を返せばよい。問題はToSpill
が返されたときである。
g
におけるLet
の処理 後編: バックトラックのキャッチ
後続命令列のレジスタ割り当てでToSpill
が返され、しかも溢れた変数の中にこのLet
で定義した変数が入っていたら、Save
命令を後続命令列の前、つまりLet
の直後に挿入する。
ほかにも溢れた変数があるなら、更に後戻りするためToSpill
を返す。
溢れた変数がほかになければ、これ以上後戻りはせずに再び(Save
を挿入した)後続命令列を対象にレジスタ割り当てをやり直す。
g_repeat
: バックトラックの最終ライン
溢れた変数がLet
で定義されたローカル変数ではなく、命令列の外から与えられた引数や自由変数であった場合、当然ながら後戻りは命令列の途中で止まらずにg_repeat
まで戻ってきてしまう。
この場合もすることは同じで、溢れた変数のSave
命令を挿入して割り当てをやり直す。
変数を割り当てるレジスタまたはレジスタから溢れる変数の選択
g
のステップ2で呼ばれるalloc
がこの決定を行う。
まずfv
によって以後の命令列cont
で生きている自由変数のリストfree
を計算する。
fv
はsparcAsm.mlで定義されている関数で、命令列で参照される変数のうち、その内部で束縛されたもの以外を返す。ただし途中にForget
があると、その変数は以後参照されないものと看做される。
次に現在それらの変数で使われているレジスタlive
、出来ればこのレジスタに割り当てられれば後でmovする手間が省けてよいというレジスタのリストprefer
を計算し、live
に含まれない空きレジスタをまずprefer
から、次いで全レジスタの中から探す。
空きレジスタがない場合、レジスタから変数を溢れさせる。そのため、free
に含まれる変数で現在レジスタに載っているものの中で、最も次に使われるタイミングが遅いものを選んでSpill
を返す。
Forget
とやり直しによるレジスタ割り当ての改善
命令列に挿入されたForget
がやり直しにおいてレジスタ割り当てを改善するのは、fv
がForget
された変数をそれ以後参照されないと看做すためである。これにより、その変数の生存期間は直前に参照された時点までで切り詰められ、そこからForget
挿入までの区間でレジスタが明け渡される。
その結果、レジスタ溢れを起こした命令では空きレジスタが与えられ、レジスタ割り当てが成功する。また、明け渡されたレジスタはその区間で別の変数に使うことができ、その変数に使う筈だったレジスタは更に別の変数に使えるので、register targetingを成功させる方向でレジスタ割り当てが改善される可能性がある。
一方、この区間で以前に別の変数がレジスタ溢れを起こしていた場合に、新たに空いたレジスタの利用でそれが解消できるのではないかという疑問が湧く。
考えてみたところではそれはなさそうに思える。
まず、仮定から前後関係は「変数x
の直前の参照→Forget(y)
→Forget(x)
→x
の次の参照→Restore(y)
」となる(変数y
は以前の試行で溢れた変数。変数x
は今回の試行で新たにレジスタ溢れとなった変数。Forget(y)
からRestore(y)
の間にy
の参照はない)。
Forget(x)
の時点で二つの変数が溢れることは変えられないので、いずれにしろForget(y)
は必要になる。
レジスタ不足以外の要因によるバックトラック: 関数呼び出しとif
レジスタが不足したとき以外にも変数がレジスタから外れ、バックトラックを起こすことがある。
それが関数呼び出しと条件分岐である。
これらはそれぞれ一つの仮想命令なのでg'
に渡されるが、実際の処理は専用の関数g'_call
とg'_if
に書かれている。
MinCamlではcaller-saveなのでレジスタは関数呼び出しで破壊されたと看做す。つまり全ての変数の生存期間はそこで区切られるので、全変数のForget
を呼び出しの直後に挿入してバックトラックする(関数の戻り値を受ける変数を除く。その変数の生存期間は呼び出しの戻った時点から始まるからである)。
これによって呼び出しの引数にならない変数は早めにレジスタを空けることになり、引数になる変数のregister targetingが成功しやすくなる。
条件分岐はもう少し話が複雑になる。
条件分岐の枝はそれぞれ独立した命令列としてg_repeat
でレジスタ割り当てを行う。つまりこれらの中でバックトラックは完結し、外には出てこない。
分岐が合流する時が問題で、まず、分岐合流時点で信じられるレジスタ割り当てregenv'
を計算する。これは両方の枝の終了時点を比較して、同じレジスタに同じ変数が載っているという共通部分だけを取り出したものになる。
次に、合流後に使われる変数でregenv'
に載っていないものをリストアップする。これらは枝のどこかで破壊されてしまうので、合流後も参照されるからといって分岐命令まで生かしておくことはない。そこでそのような変数は全てForget
を分岐命令の直後に挿入してバックトラックする。
これらのForget
は枝の中で参照される変数の生存までは妨げない。
regAlloc.mlで定義される関数の概要
target src dest cont
- 変数
src
が以後の命令列で特定のレジスタで使われることがわかればそのようなレジスタのリストを返す (register targeting)
target' src (dest, t) exp
target
の下請け。
target_args src all n list
target
の下請け。
alloc dest cont regenv all x
- 変数
x
を割り当てるレジスタr
を決めてAlloc(r)
を返す。空きレジスタがなければレジスタから溢れる変数y
を決めSpill(y)
を返す。
add x r regenv
- 変数
x
とレジスタr
の対応をregenv
に追加する。
x
がレジスタであれば何もしない。尚、MinCamlでは変数もレジスタも同じく文字列(Id.t
)で表す。区別はその名前がレジスタの名前か否かでしかない。
find x t regenv
- 変数
x
に対応するレジスタを返す。x
がregenv
に登録されていなければ例外NoReg(x,t)
を発生させる。x
がレジスタであればそのまま返す。
find' x' regenv
id_or_imm
のx'
を受け取り、即値ならそのまま返し、変数ならfind
によりレジスタを返す。
forget_list xs e
insert_forget
の下請け。
insert_forget xs exp e
- 命令
exp
の後にリストxs
にある変数のForget
を挿入して後続命令列e
と連結した命令列を作り、ToSpill
を返す。g'_if
やg'_call
で複数の変数をまとめてForget
するために使われる。
g dest cont regenv e
- 命令列
e
のレジスタ割り当て。
g'_and_restore dest cont regenv exp
g'
を呼び、NoReg
エラーが発生したらRestore
を挿入。
g' dest cont regenv exp
- 命令
exp
のレジスタ割り当て。
g'_if dest cont regenv exp constr e1 e2
- 仮想命令
IfEq
等のレジスタ割り当て。
g'_call dest cont regenv exp constr ys zs
- 仮想命令
CallDir
, CallCls
のレジスタ割り当て。
g_repeat dest cont regenv e
- 関数本体やメインの式というひとまとまりの命令列をレジスタ割り当て。
h fundef
- 関数のレジスタ割り当て。
f prog
- プログラム全体のレジスタ割り当て。