10. 万華鏡: 結論とその他の役立つ LLVM のヒント

10.1. チュートリアルの結論

LLVM を使用した言語の実装」チュートリアルの最終章へようこそ。このチュートリアルを通して、私たちの小さな万華鏡言語は、役に立たないおもちゃから、少し興味深い(が、おそらくまだ役に立たない)おもちゃへと成長しました。:)

私たちがどこまで来たか、そしてどれだけ少ないコードで済んだかを見るのは興味深いことです。私たちは、レキサー、パーサー、AST、コードジェネレーター、インタラクティブなランループ(JIT付き!)、そしてスタンドアロンの実行可能ファイルでデバッグ情報を出力するすべてを、1000行未満の(コメント/空白以外の)コードで構築しました。

私たちの小さな言語は、いくつかの興味深い機能をサポートしています。ユーザー定義の二項演算子と単項演算子をサポートし、即時評価には JIT コンパイルを使用し、SSA 構造によるいくつかの制御フロー構造をサポートしています。

このチュートリアルの目的の一部は、言語を定義、構築、および使用することが、いかに簡単で楽しいかを皆さんに見てもらうことでした。コンパイラーの構築は、恐ろしい神秘的なプロセスである必要はありません!基本をいくつか見たので、コードを取り上げてハックすることを強くお勧めします。たとえば、次を追加してみてください。

  • グローバル変数 - グローバル変数は、現代のソフトウェアエンジニアリングでは疑わしい価値を持っていますが、万華鏡コンパイラーのような簡単なハックをまとめる場合に役立つことがよくあります。幸いなことに、現在の設定ではグローバル変数の追加が非常に簡単です。未解決の変数がグローバル変数シンボルテーブルにあるかどうかを値のルックアップで確認してから、それを拒否するだけです。新しいグローバル変数を作成するには、LLVM GlobalVariable クラスのインスタンスを作成します。

  • 型付き変数 - 万華鏡は現在、double 型の変数のみをサポートしています。これにより、言語は非常に優雅になります。なぜなら、1つの型のみをサポートするということは、型を指定する必要がないことを意味するからです。異なる言語では、これの処理方法が異なります。最も簡単な方法は、ユーザーがすべての変数定義の型を指定することを要求し、変数型を Value* とともにシンボルテーブルに記録することです。

  • 配列、構造体、ベクターなど - 型を追加すると、あらゆる種類の興味深い方法で型システムを拡張できます。単純な配列は非常に簡単で、多くの異なるアプリケーションに非常に役立ちます。それらを追加することは、主に LLVM getelementptr 命令の仕組みを学ぶための演習です。これは非常に素晴らしく/型破りであるため、独自の FAQ があります!

  • 標準ランタイム - 現在の言語では、ユーザーが任意の外部関数にアクセスでき、「printd」や「putchard」などのことに使用しています。言語を拡張してより高レベルの構造を追加すると、多くの場合、これらの構造は、言語が提供するランタイムへの呼び出しに低下するのが最も理にかなっています。たとえば、ハッシュテーブルを言語に追加する場合、それらすべてをインライン化する代わりに、ルーチンをランタイムに追加するのがおそらく理にかなっています。

  • メモリ管理 - 現在、万華鏡ではスタックにのみアクセスできます。標準の libc malloc/free インターフェイスへの呼び出し、またはガベージコレクターのいずれかを使用してヒープメモリを割り当てることができると便利です。ガベージコレクションを使用したい場合は、LLVM がオブジェクトを移動し、スタックをスキャン/更新する必要があるアルゴリズムを含む、正確なガベージコレクションを完全にサポートしていることに注意してください。

  • 例外処理のサポート - LLVM は、他の言語でコンパイルされたコードと相互運用する ゼロコスト例外の生成をサポートしています。すべての関数がエラー値を返してそれをチェックするように暗黙的に行うことで、コードを生成することもできます。setjmp/longjmp を明示的に使用することもできます。ここには多くの異なる方法があります。

  • オブジェクト指向、ジェネリック、データベースアクセス、複素数、幾何プログラミング、… - 本当に、言語に追加できるクレイジーな機能は無限にあります。

  • 珍しいドメイン - 私たちは、多くの人が関心を持っているドメイン、つまり特定の言語のコンパイラーを構築することに LLVM を適用することについて話してきました。ただし、一般的に考えられていないコンパイラーテクノロジーを使用できる他の多くのドメインがあります。たとえば、LLVM は OpenGL グラフィックスアクセラレーションの実装、C++ コードから ActionScript への変換、その他多くのかわいくて巧妙なものに使用されています。もしかしたら、あなたは LLVM を使用して正規表現インタープリターをネイティブコードに JIT コンパイルする最初の人物になるかもしれませんか?

楽しんでください。クレイジーで珍しいことをしてみてください。他の人と同じように言語を構築することは、少しクレイジーなことや型破りなことを試してみて、それがどのように展開するかを見るよりも、はるかに面白くありません。行き詰まったり、それについて話したい場合は、LLVM フォーラムに投稿してください。そこには言語に関心があり、しばしば手を差し伸べてくれる人がたくさんいます。

このチュートリアルを終える前に、LLVM IR を生成するための「ヒントとコツ」についてお話したいと思います。これらは、それほど明確ではないかもしれないが、LLVM の機能を活用したい場合に非常に役立つ、より微妙なものです。

10.2. LLVM IR の特性

LLVM IR 形式のコードに関するいくつかの一般的な質問があります。今すぐそれらを片付けましょう。

10.2.1. ターゲットの独立性

万華鏡は「ポータブル言語」の例です。万華鏡で記述されたプログラムは、それが実行されるすべてのターゲットで同じように動作します。他の多くの言語、たとえば lisp、java、haskell、javascript、python などもこの特性を持っています(これらの言語はポータブルですが、すべてのライブラリがそうであるわけではないことに注意してください)。

LLVM の優れた点の 1 つは、多くの場合、IR でターゲットの独立性を維持できることです。万華鏡でコンパイルされたプログラムの LLVM IR を取得して、LLVM がサポートする任意のターゲットで実行できます。LLVM がネイティブにサポートしていないターゲットで C コードを出力してコンパイルすることさえできます。万華鏡コンパイラーがターゲットに依存しないコードを生成していることは、コードを生成するときにターゲット固有の情報をクエリしないため、簡単にわかります。

LLVM がコード用のコンパクトでターゲットに依存しない表現を提供しているという事実は、多くの人々を興奮させています。残念ながら、これらの人々は通常、言語の移植性について質問するときに、C または C ファミリーの言語について考えています。私が「残念ながら」と言うのは、(完全に一般的な)C コードを移植可能にする方法は、ソースコードを配布する以外には本当にないからです(そしてもちろん、C ソースコードも一般的には移植可能ではありません。本当に古いアプリケーションを 32 ビットから 64 ビットに移植したことはありますか?)。

C(繰り返しますが、その完全な一般性において)の問題は、ターゲット固有の前提が非常に多いことです。簡単な例を挙げると、プリプロセッサは入力テキストを処理するときに、コードからターゲットの独立性を破壊的に削除することがよくあります。

#ifdef __i386__
  int X = 1;
#else
  int X = 42;
#endif

このような問題に対して、より複雑なソリューションを考案することは可能ですが、実際のソースコードを配布するよりも優れた方法で、完全に一般的に解決することはできません。

とは言え、移植可能にできる C の興味深いサブセットがあります。プリミティブ型を固定サイズ(たとえば、int = 32 ビット、long = 64 ビット)に修正し、既存のバイナリとの ABI 互換性を気にせず、その他のマイナーな機能をあきらめることを厭わなければ、ポータブルコードを作成できます。これは、カーネル内言語などの特殊なドメインに適している可能性があります。

10.2.2. 安全性の保証

上記の言語の多くは、「安全な」言語でもあります。Java で記述されたプログラムが、アドレス空間を破壊してプロセスをクラッシュさせることは不可能です(JVM にバグがないと仮定した場合)。安全性は、言語設計、ランタイムサポート、および多くの場合、オペレーティングシステムのサポートの組み合わせが必要となる興味深い特性です。

LLVM で安全な言語を実装することは確かに可能ですが、LLVM IR 自体は安全性を保証するものではありません。LLVM IR は、安全でないポインターキャスト、解放後のバグ、バッファーオーバーラン、およびその他のさまざまな問題を許可します。安全性は LLVM の上にレイヤーとして実装する必要があり、都合の良いことに、いくつかのグループがこれを調査しています。詳細に関心がある場合は、LLVM フォーラムでお問い合わせください。

10.2.3. 言語固有の最適化

LLVMについて多くの人が敬遠する理由の一つは、LLVMが単一のシステムで世界のすべての問題を解決するわけではないということです。具体的な不満として、LLVMは高水準言語固有の最適化を実行できないと認識されていることがあります。つまり、LLVMは「情報を失いすぎる」ということです。これについていくつか考察します。

まず、LLVMが情報を失うのは事実です。例えば、この記事を書いている時点では、LLVM IRで、SSA値がILP32マシン上のCの「int」から来たのか、Cの「long」から来たのかを区別する方法はありません(デバッグ情報以外)。両方とも「i32」値にコンパイルされ、元の情報が失われます。ここでより一般的な問題は、LLVMの型システムが「名前による等価性」ではなく「構造による等価性」を使用していることです。このことが人々を驚かせるもう一つの例は、高水準言語で構造が同じ(例えば、単一のintフィールドを持つ2つの異なる構造体)2つの型がある場合です。これらの型は単一のLLVM型にコンパイルされ、それが何から来たのかを判別することは不可能になります。

次に、LLVMが情報を失うのは事実ですが、LLVMは固定された目標ではありません。私たちはさまざまな方法でLLVMの機能強化と改善を続けています。新しい機能(LLVMは常に例外やデバッグ情報をサポートしていたわけではありません)を追加するだけでなく、最適化のために重要な情報(例えば、引数が符号拡張またはゼロ拡張されているか、ポインタのエイリアス情報など)をキャプチャするようにIRを拡張しています。多くの機能強化はユーザー主導です。人々が特定の機能をLLVMに含めることを望むので、彼らはそれらを拡張していきます。

第三に、言語固有の最適化を追加することは可能で簡単であり、その方法はいくつかあります。簡単な例として、特定の言語用にコンパイルされたコードについて「知っている」言語固有の最適化パスを簡単に追加できます。Cファミリーの場合、「標準Cライブラリ関数について知っている」最適化パスがあります。main()で「exit(0)」を呼び出すと、Cが「exit」関数の動作を規定しているため、それを「return 0;」に最適化しても安全であることを知っています。

単純なライブラリ知識に加えて、さまざまな言語固有の情報をLLVM IRに埋め込むことができます。特定のニーズがあり、行き詰まった場合は、llvm-devリストでその話題を持ち上げてください。最悪の場合でも、LLVMを「単純なコードジェネレーター」として扱い、高レベルの最適化をフロントエンドで言語固有のAST上で実装することができます。

10.3. ヒントとコツ

LLVMを扱ったり、使用したりするうちに知るようになる、一見すると明らかではないさまざまな役立つヒントとコツがあります。誰もがそれらを再発見するのを避けるために、このセクションではこれらの問題についていくつか説明します。

10.3.1. 移植可能なoffsetof/sizeofの実装

コンパイラによって生成されたコードを「ターゲットに依存しない」状態に保とうとする場合に発生する興味深いことの1つは、LLVM型のサイズや、llvm構造体のフィールドのオフセットを知る必要がよくあるということです。たとえば、メモリを割り当てる関数に型のサイズを渡す必要がある場合があります。

残念ながら、これはターゲットによって大きく異なります。例えば、ポインタの幅は明らかにターゲット固有です。しかし、getelementptr命令を使用する巧妙な方法があり、これにより移植可能な方法でこれを計算できます。

10.3.2. ガベージコレクションされたスタックフレーム

一部の言語では、スタックフレームを明示的に管理したい場合があります。多くの場合、ガベージコレクションのためや、クロージャの実装を簡単にするためです。これらの機能を明示的なスタックフレームよりも優れた方法で実装できることが多いのですが、必要な場合はLLVMはそれらをサポートしています。これには、フロントエンドがコードを継続渡しスタイルに変換し、末尾呼び出し(LLVMもサポートしています)を使用する必要があります。