LLVM backend:コンパイラのための IR へ下ろす

C backend は C の便利さに寄りかかれた —— 式、入れ子の構造、期待通りに振る舞う `if`。LLVM IR はそのどれもくれない:静的単一代入、明示的な基本ブロック、明示的で低水準な型、そして二つの分岐が再合流するところには必ず phi ノード。そこへ下ろすとは、下ろす作業のより多くを自分でやることだ —— そして代わりに、世界水準の最適化器とネイティブコード生成器を手に入れる。

merebackendsllvmssacodegenlanguage-design

C backend は Mere を、人のための別の言語へ翻訳した —— 式があり、入れ子の構造があり、局所変数が あり、期待通りに振る舞う if がある言語へ。次の backend はそれを、まったく違う読み手のために 作られた何かへ翻訳する:LLVM の中間表現、コンパイラが消費したがる形式だ。サポートすべき feature は、C backend がすでに覆ったのと同じものだ。変わるのは、下ろす作業のどれだけを自分で やらねばならないかだ。ターゲットが、C が静かにやってくれていたほぼすべてを剥ぎ取ったからだ。

便利さのないターゲット

C から LLVM IR への移行を定める三つの違いがあり、そのそれぞれが、作業をターゲットからコンパイラ へ移す。

一つ目は静的単一代入 (SSA) だ。LLVM IR ではあらゆる値が、仮想レジスタにちょうど一度だけ 書き込まれる —— %1%2、と、尽きない供給で、実際のマシンレジスタへの対応づけは後で LLVM が 決めるに任される。変数を再代入せず、新しいレジスタを生む。その一つの規則が下流のすべてを作り 変え、その最も鋭い帰結が制御フローだ。

二つ目は、構造化された if がないことだ。C は backend に cond ? a : b と書かせて先へ 進ませた。LLVM IR にあるのは基本ブロックと分岐だけだ:if をコンパイルするには、条件を i1 と して計算し、二つのラベル付きブロックへ条件分岐し、各ブロックに自分の値を計算させ、そして —— SSA が一つの変数に二つの異なる値を持つことを禁じるから —— 合流点で二つの結果を phi ノードで 和解させる。phi は「この値は、どのブロックから来たかに応じて、届いた方だ」と言う命令だ。慎ましい if/then/else が、四つのブロックと一つの phi になる。言語のあらゆる分岐、match のあらゆる腕が、 このやり方で下りる:タグを取り出し、各コンストラクタと icmp し、条件分岐の連鎖、そして腕を 再び merge する phi。

三つ目は、型が明示的でマシンに近いことだ。bool は i1 で、表示できる前に i32 へゼロ拡張 される;ポインタは ptr;タプルは名前付き構造体型 %tuple_int_str = type { i32, ptr } で、 リテラルで埋めるのではなく insertvalue で一命令ずつ組み立て、extractvalue で読み戻す。heap 構造体へのアドレッシングは getelementptr だ。C なら #include できたランタイムヘルパは、代わりに IR の中でインライン定義される —— 文字列連結が mallocstrlenmemcpy として綴り出される —— そして putsstrcmp のような関数は宣言され直接呼ばれる。いくつかは難しくなるのでなく 易しくなる:LLVM はモジュール内の前方参照を許すので、C が相互再帰に要した前方宣言は、単に要らない。

同じ梯子を、二度目に登る

LLVM backend を建てることの際立った点は、それがどれほど正確に C backend の足跡を辿り直したか だ。同じ feature が同じ順で来た —— 整数、文字列、タプル、レコード、match を伴う variant、 第一級関数、捕捉を伴うクロージャ、単相化された多相型、再帰 variant、完全なパターン言語、show、 そして region と with と view のメモリモデル丸ごと —— そのそれぞれが小さなスライスで、C backend が すでに翻訳した構文を、だが今度は C の式ではなく SSA と基本ブロックへ翻訳する。クロージャは 依然として関数ポインタと環境ポインタの二語の対だ;ただそれが今や、コンパウンドリテラルの C 構造体 ではなく、insertvalue で組み立てられる %closure_T1_T2 = type { ptr, ptr } だというだけだ。 最後のスライスまでに、LLVM backend は C backend がやった全 feature を、並列に覆った。

同じ梯子を二度登ることは無駄ではない;それは第二の種類の検査だ。C backend は、言語が人向けの ターゲットへ下ろせることを証した;LLVM backend は、それがコンパイラ向けのターゲットへも下ろせる ことを証す —— C の構造を一切持たず、基本ブロックと phi ノードと明示的な型を自分で供給する ターゲットへ。その両方の、まったく違う形へ綺麗に下りる feature は、翻訳を生き延びるほど明確に 定義された feature であり、それは静かな形の設計検証だ。そして両方の lowering が同じインタプリタに 保たれるから、いまや三つの実装が byte 単位で一致せねばならない —— インタプリタ、C 出力、LLVM 出力 —— そして新しい一つひとつが、二回前の差分テストをより厳しくする。

C がすでにどこでも走るのに、なぜ

C backend がすでに可搬なネイティブコードを生むなら、LLVM IR を手で吐くこと —— C が無料でやった 下ろしをすべてやること —— には理由が要る。理由は、IR の向こう側にあるものだ。LLVM の最適化器と ネイティブコード生成器は存在する中で最良の部類で、Clang、Rust、Swift の背後にある同じ機構だ: レジスタ割り当て、命令選択、インライン化、積極的な最適化、そして LLVM がターゲットとするあらゆる プラットフォーム向けの codegen。IR を吐くことで、Mere はプログラムをその基盤に手渡し、そのすべてを 継承する。取引は明示的で意図的だ —— 下ろす作業のより多くを前もってやり、その代わりに、プロ水準の 最適化と、プロジェクトの誰も書かなくてよいネイティブコードを得る。

それがこの Part 全体を貫く選択の形だ:到達範囲と単純さのための C、最適化のための LLVM、基準の ための インタプリタ、すべてを同一の出力に保つ。二つのコード生成器がいま存在し、feature ごとに 一致し、互いとインタプリタに対して byte 単位で検証されている。自然な問いは、その一致が、小さく ないプログラム —— 十行のデモではなく千行のパーサ —— との接触を生き延びるかだ。次回:スケールで 同一に保つ —— 千行を超える現実的なプログラムが、あらゆる backend でまったく同じバイトを生む、 そしてそれを維持するのに何がかかるか。

← Back to Mere: 言語を作る