Gauche:VM命令セットの変更とビルド
Shiro(2008/07/04 03:06:56 PDT): 忘れないようにメモ。
svn trunkからビルドする場合、Gauche自身で 書かれたコードをプリコンパイルする必要がある。このプリコンパイルには 既にインストールされている、その時点での最新リリースのGaucheを使うことに なっている。以降、コンパイルに使うGaucheを"host"、それによって ビルドされるGaucheを"target"と呼ぶ。
Gaucheのプリコンパイルは、hostのコンパイラを一度通して、 結果として得られるコードベクタをCの静的配列としてダンプすることによって 行われる。ここで問題となるのは、targetのVMの命令セットをhostと 非互換な方法で変更した場合だ。VM命令セットの変更には3つの場合がある。
- hostに無い命令をtargetに追加
- hostにあった命令をtargetでは削除
- 同じ命令(ニーモニック)に違うopcodeを割り当てる
1.は問題にならない。hostのコンパイラは新しい命令を出すことはない。
2.は、hostのコンパイラが削除される命令を出してしまった場合に問題となるが、 これは解決しようがないので運用で回避する。つまり、命令を削除する場合は (1)あるリリースでコンパイラを変更し、その命令が決して出力されないようにする、 (2)次以降のリリースで実際に命令を削除する、の2ステップを踏む。 これもあまり問題にはならない。
困るのは3.のケースだが、これも基本的には対策が入っている。 hostのコンパイラを使って得られたコードベクタはそのまま 吐き出されるのではなく、一度vm-code->listを使ってSchemeから 触れる形に変換される。vm-code->listの出力ではVMの各命令は (整数のopcodeではなく)ニーモニックで表現される。 vm-code->listはhostにコンパイルインされているC subrだから、 hostのopcodeからニーモニックへの変換テーブルを使う。
vm-code->listの出力をプリコンパイラは改めてコードベクタに パックするが、ここでのパッキングにはtargetの gauche.vm.insnモジュールを使う。ここでニーモニックは targetのopcodeへと変換される。 と、ここまではGauche:VMの最適化:For 0.8.4にも書いていたこと。
これで原則としてはokなのだが、うまくいかない場合がひとつだけある。 プリコンパイルするソースに、define-inlineによって定義されている 関数がある場合だ。
define-inlineで定義された関数は、通常のコンパイル結果以外に pass1を通した後の中間結果が保存されていて、その関数が呼び出される 位置にその中間結果が展開される。この展開が、hostでのコンパイル時と targetでのコンパイル時と両方で起き得るのだ。
- プリコンパイルしているユニット中でdefine-inlineで定義された関数が、 同じユニット中で呼び出されている場合→hostコンパイラが展開を行う
- プリコンパイル済みのモジュールをtargetのgaucheがロードして、 その後で実行されるコードが上記モジュール中にあるdefine-inlineで 定義された関数を呼び出している場合→targetコンパイラが展開を行う
この中間結果は定数のベクタとして保存されているんだが、0.8.13までは、 この中間結果に$ASMノードが含まれている場合、 そこにVM命令の(hostの)opcodeが埋め込まれてしまっている。従ってVM命令のopcode がhostとtargetで違う場合、targetでのインライン展開の結果がおかしくなる 可能性がある。
例えばhostに0.8.13のコンパイラを使う、0.8.14の開発中は、 opcodeを変えたらGaucheのビルド時に-fno-inline-globalsオプションをつけて インライン展開を抑制しないとtargetが動かない。これを忘れてると色々 謎な不具合に悩まされる(特に一部のopcodeだけが変わっている場合)。
もちろんインライン展開を 抑制した場合、targetコンパイラの性能はガタ落ちなので、ちゃんとした性能を 得るためには(1)hostでtargetをコンパイル→(2)targetをインストール→(3)target でもってtarget自身を再コンパイル、という手順を踏む必要がある。
これは大変面倒くさい。
いくつか対応策は考えられる。
- プリコンパイラがhostとtargetの命令セットが異なっていることを検出した場合、 警告を表示し、-fno-inline-globalsオプションを強制的にonにする。 これで少なくとも謎な挙動に悩まされる危険は減る。ただ、性能を得るために 何度もコンパイルする手間は避けられない。
- ビルドプロセスを工夫して、命令セットの違いを検出したら強制的にtarget自身による 再コンパイルをかけるようにする。 これで一応、手間は減る。コンパイルに時間がかかるのとビルドプロセスが複雑化するのが 難点。
- define-inlineで保存する中間形式を、host用とtarget用のふたつ用意する。 define-inlineフォームの処理時に若干のペナルティがあるが、define-inline の定義はそんなに多く無いので問題ではないだろう。 展開時にペナルティは無いし、プリコンパイル結果に含めるのはtarget用だけで良い。 (中間形式をニーモニックで保存する、という方法は展開時のペナルティが 大きいのでだめ。展開は定義よりもずっと多く行われる)。
3の方式が一番有望かな。ただ、0.8.13のコンパイラはこれに対応していないので 0.8.14の開発時にこの手は使えない。
- 0.8.14で命令を追加する場合はopcodeを変えないようにする
- 0.8.14のコンパイラに3の方式を組み込んでおき、0.8.14以降 (0.8.15か0.9) の 開発時には好きにopcodeが変えられるようにする
という方針で行こうか。
2008/07/05 16:57:26 PDT: 機内ハックで上の3を動かそうとしてたのだが、 話はもっと面倒だった。 compile.scmのコンパイル時に、targetのVM命令定義を読み込んでいるので 中間形式の保存にもそれを使えばいいと思ってたんだがそれではうまく動かない。 クロスコンパイルにはつきものの話で、順番に考えればそんなに複雑じゃないんだけど、 コードをいじってると時々わからなくなるので整理しとく。
まず、コンパイラ(compile.scmをプリコンパイルして、libgaucheに含まれる コード)には、そのコンパイラが使うVM命令(ISA)が組み込まれている。 foo.scmをそれでコンパイルして出来るVM命令列をfoo<ISA>と表記する。
compile v foo.scm -----------> foo<ISA>
compile自身もcompile.scmをコンパイルして出来た結果であるから、普段の gaucheの動作はこうなっている。通常はcompile自身が走るVMでfooも走らせる わけだから、compileとfooのISAは等しい。
compile.scm ----------> compile<ISA> v foo.scm -----------> foo<ISA>
コンパイラが「ISAを持つVMで走らせるコードを出す」ことを[]で示す。
compile.scm ----------> compile<ISA>[ISA] v foo.scm -----------> foo<ISA>
foo<X>は「ISA XのVMで走るコード」、ということ。
compile<X>[Y] は 「ISA XのVMで走り、『ISA YのVMで走るコード』を生成するコンパイラ」
ということ。
ここで、hostのISAをH、targetのISAをTと書く。プリコンパイラgencompは、 まず自身のコンパイラで対象コードをコンパイルした後、それをtargetの gauche.vm.insnを使ってターゲット用のコードに変換している。
gencomp<H> | +---------+---------+ v | compile.scm ----------> compile<H>[H] target's gauche.vm.insn v v foo.scm --------> foo<H> -----------> foo<T>
コンパイラ自身をコンパイルする場合も同じ。hostのコンパイラが生成するのは あくまでISA HのVMで走るコード。ただし、compile.scm内でtargetの gauche.vm.insnを読み込んでるため、中間生成物は「自身はISA HのVMで走るが、 『ISA TのVMで走るコード』を生成する」コンパイラとなる。 これも上と同じく、プリコンパイラの後段によってISA Tで走るコードへと 変換され、最終的には「ISA TのVMで走り、『ISA TのVMで走るコード』を生成する コンパイラ」となる。
gencomp<H> | +---------+---------+ v | compile.scm ----------> compile<H>[H] target's gauche.vm.insn v v compile.scm -----> compile<H>[T] -----> compile<T>[T] ^ target's gauche.vm.insn
さてさて、今回の話は、一般のSchemeファイルにdefine-inlineが含まれている ものをプリコンパイルするということであった。例えばbarという関数が define-inlineで定義されてるとする。hostコンパイラはbarをIFormに変換 し、foo内でbarが呼ばれた箇所でそれを展開する。IFromにはISA Hのopcode が含まれているが、最終的にそれが展開されていさえすれば、プリコンパイラの後段が HからTへの変換の面倒をみてくれる。
しかし、「fooを後でimportして使う別のコード」がインライン展開に使うために、 中間表現のIFormも保存しておかなければならない。保存されたIFormは 一般のSchemeの定数ベクタと区別がつかないので、プリコンパイラ側の HからTへの変換から漏れていた。
gencomp<H> | +---------+---------+ v | compile.scm ----------> compile<H>[H] target's gauche.vm.insn v v foo.scm ---------------> foo<H> ---------------> foo<T> (define-inline bar..) IForm(bar)<H> IForm(bar)<H> Expand(bar)<H> Expand(bar)<T>
こうしてIForm(bar)<H>が埋め込まれたfoo<T>を、 別のプログラムfoo2がuseしているものをターゲットコンパイラでコンパイルすると、 ISA Hがfoo2<T>の中に展開されちゃうのでおかしなことになっていたわけだ。
compile<T>[T] v foo2.scm --------------> foo2<T> ^ Expand(bar)<H> |use | foo<T> IForm(bar)<H>
と、当然のことを長々と書き連ねたのは、compile.scmをいくらいじっても この問題は解決しないということを理解するためであった。 compile.scmは確かにtargetのgauche.vm.insnを読み込んでいるが、 それはcompile<X>[Y]のYの部分に影響を与えるためであって、 ここでの問題とは無関係なのだ。(compile<H>[H]自身はどうがんばっても foo.scmのコンパイル時にIForm(bar)<T>を生成することはできない。 foo.scmは一般のプログラムで、gauche.vm.insnを読み込んでいるわけじゃないのだから)。
コンパイラのオプションとして、compile<H>[H]が自身の実行時に ターゲットのgauche.vm.insnを読み込んでcompile<H>[T]へと変身するという 手段はある。あるんだが、そういうクロスコンパイルなんて非常に特殊な ケースでしかなくて、そのためにcompile.scmを複雑にするのはちょっと嫌だ。 だいたいそれをやるならわざわざプリコンパイラを前段と後段に分ける必要も なかったわけで。
今狙ってる方法は、保存されたIFormを単なる定数と区別できるようにして、 プリコンパイラの後段がopcodeの変換をかけられるようにすることである。
実装した。compile.scmのpass1/define-inlineおよび gencompのcheck-packed-inliner参照。