コンパイラでもっとレジスタを活用するために行った対策

2023-08-25

自作のCコンパイラではLLVMなどのバックエンドを使わずにx86-64やaarch64のアセンブリコードを直接出力しているが、 出力するコードでより多くのCPUレジスタを使えるように改良した。 他にも不要にメモリ(スタック)操作をしていたのを対策した。

内容は:

  1. 関数呼出のレジスタ渡し引数をいったんスタックに積んでいたのをやめる
  2. レジスタ割付の対象とする物理レジスタを増やす
  3. 関数への引数を必ずメモリに割付(Spill)するのをやめて、可能ならレジスタを使用する

の3つ。

対策前の状態

利用できていたレジスタ:7/16

変数の値や式の途中の計算式をアセンブリで扱う際にレジスタ割付によってある程度レジスタに保持するようにしていた。 ただ汎用レジスタのすべてを使用できてはおらず、一部のみの利用にとどまっていた。

例えばx86-64では16本の汎用レジスタがあるが9本は使えず7本までしか割付の対象にできていなかった。 使えていなかったのは:

  • %rdi, %rsi, %rdx, %rcx, %r8, %r9: 呼出規約でのレジスタ渡し用
    • %rdxは乗算や除算でも利用される
  • %rax: 関数の戻り値、また乗算や除算で利用される
  • %rbp: ベースポインタ
  • %rsp: スタックポインタ) ←常にスタック操作で必要なので対象外

呼出規約で決められているレジスタや、プロセッサの制限で固定されているレジスタを使えていなかった。 スタックポインタは無理としても、少なくともレジスタ渡しに使うレジスタは使えるようにしたい。

関数呼出の引数をいったんスタックに積んでからレジスタに取り出していた

関数呼出の引数でさらに関数呼出をするという具合にネストされているかもしれないので、レジスタ渡しの引数を呼出規約の引数レジスタに直接格納するのではなく、 いったんスタックに積んでおいてそれをCALL直前に各レジスタにポップしていた。 これが無駄なのでやめたい。

呼び出された関数側で受け取った引数を必ずメモリに割り付けていた

レジスタで渡された引数は、その関数内でほかの関数呼出を含んでいると破壊される可能性がある。 そういう場合にそのまま保持しておけないので、常にメモリに逃すようにしていた。 これもできれば受け取ったレジスタのまま扱えるようにしたい。

対策

関数の引数を展開する

C言語では関数呼出の引数に式を書くことができるので、さらに関数を呼び出してネストさせたり、代入などの副作用や三項演算子の条件分岐などいろいろ複雑なことができてしまう。 コンパイラの動作として外側の関数呼出用に呼出規約の引数レジスタに値を代入していってる間に、内側の関数呼出で別の値に上書きされてしまったり、また関数呼出後には引数レジスタの値は保持されていないので動作が不定になってしまう。

そこで関数の引数が単純な式ではない場合には事前に計算してテンポラリ変数に格納して、単なる変数参照になるようにする。 例えば foo(bar()) などという式だったら tmp = bar(), foo(tmp) という具合に展開してやる。

こうすることで関数呼出がネストしないようになり、呼出規約の引数レジスタに直接代入できるようになる。 (内部的にテンポラリ変数を追加してもレジスタに割り付けられれば結局はノーペナである。)

割付対象の物理レジスタを増やす:7→12

呼出規約のレジスタ渡しで使われる%rdi ~ %r9CALL 命令が使用されると値が壊される可能性があるため使わないようにしていた。 しかしCALL命令を含まない場合には問題なく使用できるので対策する。

レジスタ割付で各仮想レジスタの生存期間を調べるが、その期間内にCALL命令があるかどうかを調べて、ない場合には割付可能とする。

呼出規約ではそれらのレジスタはCallee SaveでもCaller Saveでもないので、破壊していい(ということだと思う)。 単純な計算式でもアセンブリでは二項演算ごとにレジスタを使用する必要があるので、 そういう中間バッファとして最適なので優先的に割り付けてやる。

関数の引数をレジスタ割付可能にする

上の対策を関数の引数にも適用してやれば、内部に関数呼出を含まないような末端の関数では引数を渡されたレジスタでそのまま使用すれば効率的 (上の対策以前ではレジスタ渡し用のレジスタが割付対象外だったのでそもそも利用自体が不可能だった)。 関数呼出を含んでいたとしても他の仮想レジスタと同様に、別の物理レジスタが割付可能だったらそのレジスタに、それでもダメだった場合にようやくSpillさせてスタックフレーム上に割り付けるようにする。

そうした上で関数のプロローグで各引数の割付対象によって

  • 何もしない(受け渡されたレジスタをそのまま使用)
  • 別のレジスタにMOV
  • スタックフレームにストア

のいずれかを行うようにする。

結果

上記の対策で不要なメモリ操作を抑えることができるようになった (ベンチマークを測ってないので実際の効果は不明だが…)。

課題

  • まだすべてのレジスタを使用できてない(12/16)
    • %rax%rdx が未使用。乗除算が使用されているか調べて同様の対策をする
    • %rbp:スタックフレームが不要な関数ではベースポインタを使ってしまうことができる!?
  • テンポラリレジスタが使える場合でも見逃す可能性がある
    • 関数呼出の引数構築時でもまだ割り付けてないレジスタ渡し用のレジスタは本当なら使えるが、判定の都合で対象外にしてしまっている
    • レジスタ引数構築n番目までだったらn以前のレジスタは使用可能、とかしてやる必要がある

与太話

  • 実装方法を考えていた時には仮想レジスタの用途を調べて制約を課す必要があるんじゃないか、 とするとリニアスキャンのレジスタ割付で問題起きないだろうかと懸念してた。 が今のところはせずに済ませている。
  • だんだんコンパイラの挙動を把握しきれなくなってきてることもあり実際に動作するようになるまでなかなか大変だった。 可変長引数を受け取る関数や、構造体を返す関数で問題が出たり。
  • 関数への引数を展開する処理で&&||のショートカットが無効になってしまったり。
  • 引数でインクリメントしてる場合にCaller Saveで巻き戻ってしまったり。
  • 整数型とともに浮動小数点数もレジスタ渡しで使えてなかったレジスタがあるので対応。
  • x86-64以外にaarch64(Arm64)にも対応したが、レジスタも多いし癖がないので比較的簡単だった。

リンク