自分専用のCPU

5 つ目のバックエンドを足す、と言うと能力が増えたように聞こえる。だが本当の物語はループが閉じたことだ — セルフホストのコンパイラが、言語自身がすでに書いていた CPU と出会った。新しい力の多くは、新しいターゲットではなく、二つの古い dogfood がようやく出会うという「再結合」だった。

merelanguage-designessayself-hostingrisc-v

Mere に 5 つ目のバックエンドが生えた。インタプリタ、C バックエンド、LLVM、WebAssembly に加えて、 mere -rv はプログラムをフラットな RV32IM バイナリへ直接落とすようになった — 本物の 32 ビット RISC-V 機械語で、外部アセンブラもリンカも要らない。こう書くと、能力の表にもう 1 列足しただけに 読める。その捉え方は正しいが、肝心なところを外している。このバックエンドがやった面白いことは、 ターゲットを増やしたことではない。すでに存在していた二つのものの間でループを閉じたことだ。そして 両者が出会った瞬間、この言語は自分自身が書いた機械の上に立てるようになった。

バックエンドとは意見を持ったアセンブラだ

単体で見れば、RISC-V のコード生成器はもっとも意外性のないバックエンドだ。値モデルはとうに決まって いた — 32 ビットのワードは整数かヒープへのポインタのどちらかだ — し、呼び出し規約も、クロージャの レイアウトも、文字列と Vec と Map のランタイムも、一度設計して各バックエンドで再実装してきた。 RV32IM への lowering は、要はレジスタを選び命令をエンコードすることだ。2 パス、オペコードの表、 6 つの形式に散らばった即値のビット。前に型検査器を置いたアセンブラである。もしそれが物語の全部なら、 changelog の 1 行に値するだけで、エッセイには値しない。

手間をかける価値を生んだのは、そのバックエンドを「何に向けられるか」だった。

すでにあった二つの半分

Mere にはすでにセルフホストのコンパイラがあった — レキサ、パーサ、Hindley–Milner 型検査器、そして WebAssembly のコード生成器、すべて Mere で書かれ、それぞれが数ヶ月かけて独立した dogfood として 公開されてきた。そして Mere にはすでに CPU エミュレータの小さな梯子もあった — CHIP-8、6502、 ゲームボーイ、RV32IM コア、これも Mere で書かれ、プロセッサを真似るとき何が難しいのかを学ぶために 作られ、それぞれ外部のリファレンスと照合されてきた。

無関係な理由で組まれた、二つのコードの山。5 つ目のバックエンドは、その二つを出会わせる蝶番だった。 セルフホストのコンパイラを mere -rv でコンパイルすると、およそ 380 キロバイトの RV32IM バイナリ が得られる。そのバイナリを Mere 製の RISC-V エミュレータの上で走らせ、小さな Mere プログラムを 渡してコンパイルさせると、出てくる WebAssembly は、同じ入力からネイティブのインタプリタが吐くものと バイト単位で一致する。コンパイラが、コンパイルしている、言語自身が書いた CPU の上で。

一息で言うには長い文だが、その下の構造は単純だ。言語 → その言語自身のバックエンド → その言語自身の CPU → その CPU の上で走るその言語自身のコンパイラ。この塔のどの層も、同じ一つの言語でできている。 偉かったのは RISC-V バックエンドが難しいことでは全然ない。偉かったのは、部品がすべてすでにそこに あり、それぞれ別の問いに答えるために作られていて、たった一つの控えめなバックエンドが、それらを 一枚の縦のスライスへと並べてみせたことだ。

3 つの操作でコンソールになる

ループが閉じたことで、エミュレータは「遊べるもの」にもなった。ファンタジーコンソールにフレーム ワークは要らない。要るのは小さなハードウェアの契約で、それがここでは -rv バックエンドが生の ストア・ロードとシステムコールに落とす 3 つの操作だ。

  • fb_set x y v — 固定アドレスにある 64×32 のフレームバッファへ濃淡を書き込む。
  • key n — メモリマップドの入力レジスタから、ボタンの押下状態を読む。
  • present () — フレームを終えてホストへ制御を返す。

3 つ目が、静かに面白い。present は 1 つの ecall にコンパイルされる。エミュレータはそれを見ると 実行を止め、ブラウザに制御を返してフレームバッファを転送しキーを読み取らせ、それから CPU を「まさに 次の命令」から再開する。カートリッジの視点からは、present は 1 フレームだけブロックして返る関数だ。 だからゲームのメインループはただの再帰になる — 入力を読み、動かし、描き、present し、再帰する — そして プレイヤーの位置はフレームをまたいで RISC-V の呼び出しスタックの上に載る。グローバルにも、ホスト側の 状態にも載らない。コンソールのループはコルーチンであり、その継続とは、フレームの間だけ凍らされた 機械自身のスタックにほかならない。

そのエミュレータを WebAssembly バックエンドに通し、数ヶ月前にゲームボーイのエミュレータ dogfood が 必要としたのと同じ DOM 外部関数インタフェースで <canvas> に配線すれば、全体がブラウザのタブの中で 走る。Mere が書いた CPU が、Mere が書いたゲームを実行し、URL で辿り着ける。またしても同じパターン — 「増えた」能力の多くは再結合だった。入力の配管も、canvas への転送も、フレームごとのコールバックも、 すべて別のデモのためにすでに存在していた。新しく書いたのは、2 つの extern と 1 つの契約だけだ。

どの層も自分の請求書を払った

これらは何一つ綺麗には手に入らなかった。そして、そここそが残す価値のある部分だ。セルフホストの塔は 容赦のない結合テストだ。小さなプログラムでは決して踏まないバグも、プログラムが 380 キロバイトの コンパイラになれば、隠れる場所がない。セルフホストしたバイナリは最初、空の出力を出した。原因は メモリマップの重なりだった — グローバル領域が 64 キロバイトの位置にあり、コンパイラのコードは 88 を 超えて伸びていた。だからグローバルへの書き込みがコードを壊し、実行は瓦礫へ飛んだ。コードが 64 キロバイトに収まる小さなプログラムたちは、何週間も機嫌よく通り続け、それを覆い隠していた。もう一層 下では、条件分岐の到達距離が ±4 キロバイトしかなかった。大きなプログラムの関数はそれを超える。だから すべての分岐を、より長いジャンプを飛び越える形へ書き直さねばならなかった。

そして一番古い傷が開き直した。この連載にはすでに「64 メガバイトの崖」の回がある — WebAssembly バックエンドのバンプアロケータは決して解放しないので、十分大きな実行は固定されたリニアメモリの端を 踏み外す。ブラウザのコンソールは、新しい角度から同じ崖に出会った。そのエミュレートされた RAM は 8 メガバイトの機械語ワードの配列で、それを容量倍増式のアロケータで構築すると、落ち着くまでに ピークが 1 億 8 千万バイト近くまで跳ね上がる。修正は賢くもなんともない — このモジュールだけメモリの 天井を上げて先へ進む — が、崖はまさに以前の回が「ここにある」と書いた場所で待っていた。レイトレース とは縁もゆかりもないプログラムから、いま辿り着かれたのだ。一度書き留めておいた数字が、払い続けて くれた。

閉じた塔が実際に証明するもの

これを誇張するのは簡単だ。同じ言語で書かれた CPU の上で同じ言語のコンパイラが走るからといって、 その言語が速くなるわけでも、本番向けになるわけでも、この用途に実用的になるわけでもない — エミュレート されたコンパイラはネイティブより桁違いに遅く、こんな形でソフトウェアを出荷すべき人は誰もいない。 閉じたループが買うものは、もっと狭く、そして本物だ。確信と、教材だ。確信 — インタプリタと、 エミュレートされた RV32IM バイナリとで出力がバイト単位で一致するのは、バックエンドと値モデルと ランタイムが、最後に吐かれる 1 バイトまで一致していることの、厳しいクロスチェックだからだ。教材 — いまやスタック全体が一つの言語で読めるからだ。CPU を読み、その上で走るコンパイラを読み、その コンパイラが組み立てるゲームを読む。文脈の切り替えなしに、各抽象がその下の抽象のどこに載っているかを、 正確に見て取れる。

それがこの物事の、正直な形だ。5 つ目のバックエンドは確かに Mere にできることを増やした。だがその 「増えた」は新しい目的地ではなかった。それはループだった — 別々に組まれた二つの dogfood が、ようやく 出会うことを許された — そして、部品がすべて同じ言語であるとき、ソースファイルから「自分が書いた CPU の上で走るゲーム」までの距離が、そうであってよいはずの長さより、ずっと短いという発見だった。

← Back to Notes