自分自身を一度も走らせたことのないコンパイラ
self-hosting には混同しやすい二つの意味がある。一つはコンパイラが自分自身を valid なバイナリとして emit できること;もう一つは emit されたバイナリがそれ自体で動くコンパイラであること。最初のものは何 Part も前に達成され、summit のように感じられた。それは偽の summit だった —— なぜならプログラムを emit することはそれを走らせることでなく、自己コンパイルしたコンパイラは一度も実行されたことがなかったから。初めて実行されたとき、自明な入力で即座にスタックをオーバーフローし、そうなった理由は logic のバグでなく、コンパイラが emit することを一度も学ばなかった一つの命令だった。
Mere が何 Part も前に達成した何かに使われてきた言葉がある —— self-hosting —— そしてそれは混同しやすい 二つの違うものを意味すると判明した。一つは、Mere で書かれたコンパイラが自分自身のソースを取り valid な バイナリを emit できること:あらゆる module、自分自身のコードをコンパイルするコード生成器を含め、 validator が受け入れる WebAssembly に lower される。それは為され、実で、当時それはプロジェクトまるごとの summit のように感じられた。もう一つの意味はより強く、静かにまだ手の届かないところにあった:emit された バイナリが、プログラムとして走ると、それ自体で動くコンパイラであること —— ソースファイルを手渡せば 正しい出力を生むこと。その二つの間の gap がこの Part まるごとで、それは最初の意味が決して要さない一つの ことをするまで見えないままの gap だ:実際にそれを走らせること。
一度も走らされたことのないテスト
強いほうの主張を決着させるやり方は fixpoint テストで、述べるのは単純だ。小さなプログラムを取る。二度 コンパイルする。一度目は、コンパイラを普通のやり方で、interpreter の下で走らせ、出力を取っておく。二度目 は、自分自身によって WebAssembly バイナリにコンパイルされ、いまやそのバイナリとして走っているコンパイラ で —— 同じプログラムを手渡しその出力を取っておく。二つの出力が同一で、どちらを走らせても正しいことを すれば、コンパイルされたコンパイラはコンパイラの忠実な複製だ:真の bootstrap fixpoint。構造的 self-hosting はそのテストの後半に要るバイナリを生んでいたが、そこで止まっていた。valid なバイナリを emit することとそれを実行することは違う行為で、二つ目は決して行われたことがなかった。
最初の実行
だからそれが行われ、即座に失敗した。自己コンパイルされたコンパイラは、この上なく自明なプログラムを 手渡され、間違った出力を生まなかった —— 出力をまったく生まなかった、なぜならスタックの端を走り抜けて クラッシュしたから。runtime のスタック制限を上げるという反射的な最初の一手は、何も有用なことをせず; 十分高く押し上げると、プログラムより先に runtime 自身が音を上げた。何が悪いのであれ、より多くの余地を 与えることでは直らないものだった。コンパイルされたコンパイラの何かが、限りなくスタックを消費していた。
バグでなく、限界
決定的な手がかりは比較だった。その同じ自明なプログラムが、一度目のやり方でコンパイルされ —— interpreter の下のコンパイラで —— 難なく終わり答えを生んだ。だから計算は終了した;有限だった。だからクラッシュは 無限再帰でなく、永遠にループする logic エラーでなかった。それは資源の限界だった:まったく同じ有限の計算が interpreter の寛大なスタックで完走し、WebAssembly runtime のはるかに浅いスタックをオーバーフローした。 これはこの連載で寛大なホストがすでに二度教えた同じ教訓が、新しい服を着たものだ。interpreter は、大きな native スタックの上で走り、ずっとコストを隠していた。計算がすべてのフレームを数えるスタックの上に移された ときにだけコストが見え —— そしてそれはプログラムが計算するものの欠陥でなく、それを計算するのにどれだけ 深く降りねばならないかの欠陥だった。
コンパイラが emit しなかった命令
なぜそんなに深く降りるのか? tokenizer と parser は tail-recursive なスタイルで書かれていた、ネストする でなく結果を累積してループする規律あるやつ —— まさにスタックを育てないはずの形。そしてそれは違いを 生まず、それが謎だった、原因が一段下だと判明するまで。tail-recursive なスタイルが平らなままでいられるのは、 コンパイラが末尾呼び出しをフレームを積む call でなくジャンプに変えるときだけだ。WebAssembly にはまさに これのための命令、保証された末尾呼び出しがあり、リファレンスのコード生成器はそれを emit した。self-hosted なコード生成器 —— Mere で書かれた、たった今自分自身をコンパイルしたやつ —— はしなかった。それはあらゆる call を同じやり方で、フレームを積んで return を待つ普通の call として lower した。だから tail-recursive な Mere は、self-hosted なコンパイラにコンパイルされると、それでもフレームを積み上げ、深さが入力のサイズと 共に育つ再帰 —— 長い token の list を歩く、たとえば —— はスタックが尽きるまで登った。ソースは規律正しく; ターゲットはスタックベースで;そして間のコンパイラがそれらを橋渡しするのを拒んだ。
その拒否は、コンパイラがただ emit されるだけで決してコンパイラとして走らされなかった限り、見えな かった。構造的 self-hosting は実の達成で、そして部分的なもの、後ろにより高い峰があると判明した summit だった。behavioral な fixpoint —— コンパイルされたコンパイラが実際に動くこと —— は一つの欠けた命令を待ち、 その gap がついに見えた唯一の理由は、その代物が、ついに、走らされたことだった。次の Part は、それが emit することを一度も学ばなかった命令を、それに教えることについてだ。