コンピュータは非常に複雑であると同時にたいへん愚かでもあります。コンピュータには、正確に「何をしてほしいのか」を、事細かに指示する必要があります。そのうえ、1と0からなるビットの並びが理解できるだけなので、たとえば、最新のPentiumプロセッサで2つの整数を足し算する、という単純な操作をするのに、約48ビットのビット列を必要とすることもあるのです。オペレーティングシステムを実装しようとすると何が必要になるのか想像してみてください。
言うまでもなく、通常は、ビット列を利用してコンピュータのプログラムを作成することはありません。ビット列を利用する代わりに、人間が理解できる言語を使用してすべての命令を記述し、その後でコンピュータが理解できる、1と0の適切な並びに変換します。変換の概念はコンピュータの世界の至る所で用いられています。コンピュータに指示する処理内容は、すべて、変換の過程を経る必要があります。こういった変換は、プログラムを実行しながら、インタプリテーションと呼ばれる処理で行われる場合もあれば(Perlのスクリプトやスプレッドシートなどの場合)、プログラムを起動する前に一度だけ、行われる場合もあります(CやC++のアプリケーションなどの場合)。また、JITコンパイルと呼ばれる処理でインタプリテーションとコンパイルを併用する複合型の手法もあり、この手法は人気を集めつつあります。
この記事では、GCC、すなわち、今やUnixの世界で最も一般的なコンパイラの1つになったGNU Compiler Collectionについて検討し、そのことを通じて、コンパイラ内部でどのような処理が行われているのか説明します。まず、一般的なコンパイラの構造の説明から開始し、続けて、GCCの内部構造の概要を説明します。最後に、GCCの以前のバージョンでは実装するのが困難ないしは不可能であった、より強力な機能の実現に道を開く、GCC-コア基盤のアーキテクチャ上の最近の進歩について説明します。
以下の説明は非常にレベルの高い内容の概要となることを心に留めておいてください。コンパイラはかなり複雑なソフトウェアの体系です(GCCは約210万行のコードからなり、15年以上もの間開発が続けられています)。そのため、コンパイラの詳細説明は流血現場を描写するようなものですが、うまく説明するつもりです。GCCや一般的なコンパイラに興味のある方は、「参考文献」にリストされているリンクを参照してください。
このパイプラインには、3つの主要な要素があります。フロントエンドは、入力プログラムを読み、構文を検査します。ミドルエンドは、プログラムを解析し、より効率的な形式に変換します。最後に、バックエンドは、最終的にオブジェクトコードに落とし込むのが仕事です。
一般的に、コンパイラは、入力プログラムに対し、1つ以上の表現を扱います。最初に、フロントエンドは、入力プログラムの構文解析を行い、階層構造で各命令文を表す、抽象構文木(AST:Abstract Syntax Tree)と呼ばれるデータ構造を生成します。たとえば、命令文x = y - z * 3が与えられた場合、対応するASTは図1「x = y - z * 3の抽象構文木」のようになります。
図1. x = y - z * 3の抽象構文木
フロントエンドは、入力プログラムの構文構造の正当性を検証するのが役割で、言語としての適合性に関するほとんどの診断結果の出力、プログラムで宣言されているデータ型と変数の内部データ構造の生成、ファイル名や行番号などの情報のデバッグ、および内部的なAST表現の生成を行います。
フロントエンドは、プログラムが実際にどのような処理をするのかほとんど知りません。最適化は、コンパイラがプログラムの制御の流れを理解し(コントロールフロー解析)、かつプログラム実行時にデータがどのように変換されるかを理解して(データフロー解析)、始めて可能になります。プログラムの制御とデータの流れを解析することにより、コンパイラは、オブジェクトコードの実行時パフォーマンスを向上できます。ひとたびプログラムの制御とデータの流れがコンパイラにより理解されると、多種多様な最適化が可能になります。以下に、最適化機能を持つ標準的なコンパイラで使用されている、最も定評のある最適化の手法をいくつか紹介します。
最終的なコード生成が終了しても、通常は、生成されたコードはそのまま実行できる状態にはなっていません。コンパイラによっては、GCCはその中の1つですが、アセンブリコードが生成されます。そのアセンブリコードがアセンブラに渡され、アセンブラによりオブジェクトコードが生成されます。オブジェクトコードが生成された後は、リンカにより、必要なオブジェクトファイルやライブラリが種々結合され、最終的な実行可能プログラムが生成されます。
図2. GCCの既存のコンパイル処理
フロントエンドとミドルエンドが一種の融合状態にある様子に注目してください。その理由は、GCCの処理がそれほど「Tree」に基づいて行われてはいないことにあります。「Tree」は、主に、RTLを生成する際の足掛かりとして使用されます。最近まで、「Tree」に基づいて行われた唯一の処理は関数のインライン化でした。他のほとんどの処理は RTLオプティマイザにゆだねられていました。一面では、これは意味のあることです。なぜなら、図から分かるように、各言語のフロントエンドは独自の形式の「Tree」を生成しているからです。たとえば、Cの構文解析ではC木が生成され、C++の構文解析ではC++木が生成されます。各言語の「Tree」は、独自の方式があり、異なっています。そのため、「Tree」に基づく解析や分析にはN個の異なる実装、つまり各フロントエンドに対し1つの実装が必要になります。これにはほとんど拡張性がなく、そのため、すべての最適化の処理はRTL内で行われます。
RTLは、無限大の個数のレジスタを持つマシンに対応したアセンブリ言語と考えられます。低レベルの表現であるため、RTLは、レジスタの割り付け、遅延スロットの最適化、peephole最適化などの、目的に密着した最適化にはうまく機能します。加えて、GCCを新しいアーキテクチャに移植する際に開発者が目的とするマシンを記述するための、かなり柔軟性にとんだ方法が、GCCにより提供されています。事実、GCCの最初の実装では、RTLはほぼ起動直後に生成されたため、コンパイラのほとんどの部分がRTLを中心にして実装されたのは自然な成り行きです。
しかし、ある種の解析や変換では、RTLから入手するのが不可能ないしは困難な、プログラムに関するより高度な情報が必要になります(たとえば配列の参照、データ型、プログラム変数の参照、制御の流れの構造など)。一般的に、RTLには、やり方のまずい目的機能が多すぎます。たとえば、目的とするマシンのレジスタが32ビットを超える数値を処理できない場合、RTLの表現では、32ビットより大きな値は複数の値に分割されます。また、関数呼び出しの仕様のような情報もRTLに詳細に置かれるため、1つの関数呼び出しが複数のRTL命令にまたがることがあります。これらのすべての詳細情報は、目的事項の詳細と関係を持つ必要がまったくない解析や最適化を実装するのを困難にする原因になっています。長い間に、いくつかの変換がRTLに実装されましたが、最後に得られる生成コードは、過度に入り組んだ、保守が困難で、エラーが生じやすいコードになっています。
この新しい基盤は、最近の何年かに生じた、GCCに関する大きな変化の代表的なものです。GCCコミュニティの30以上の開発業者の協力を得て基本機能を完成するのに、約3年かかりました。フレームワークは静的単一代入(SSA: Static Single Assignment form)と呼ばれるコンパイラ形式を基礎にしています。SSAは、プログラム内でデータがどのように流れるのか追跡するために、コンパイラにより内部的に使用される表現です。この表現の情報は、コンパイラが最適化のための決定を導き出すための、本質的な情報です。「Tree」のデータ構造上に構築され、かつSSAが使用されることから、このフレームワークはTree SSAと名づけられました。
図3. Tree SSAによるGCCのコンパイル処理
GENERICへ変換することで、プログラムの表現から言語依存性を取り除きます。しかし、なおGENERIC「Tree」は構造的に複雑なため、次の段階でさらに取り扱いやすい要素に分解されます。GIMPLEと呼ばれる新しい表現は、辞書的にはGENERICと同じですが、命令文に構造上の制約を付加します。たとえば、3つ以上のオペランドを持つ表現は許されない、関数を呼び出すときの引数には変数だけが許される(すなわち、関数の呼び出しをネストすることは許されない)、if文の述部には比較が1つのみ許さる、などの制約です。この形式は、McGill大学のMcCATコンパイラで使用されているSIMPLE表現[1]を取り入れたものです。
「GENERICとGIMPLE」と表題が付けられた節のプログラムにより、GENERICとGIMPLEの違いが示されています。図の中の形の異なる2つのプログラムは同じプログラムですが、GIMPLE形式で表現したプログラムの方がより簡潔になっていることに注目してください(長くはなっていますが)。これは、オプチマイザにとって物事をより易しくするための、キーとなる特性です。なぜなら、最終的には保守と改良がより容易なコンパイラにとって役に立つ、種々の単純化の前提になりうるからです。
GENERIC GIMPLE if (foo (a + b, c)) t1 = a + b c = b++ / a t2 = foo (t1, c) endif if (t2 != 0) return c t3 = b b = b + 1 c = t3 / a endif return c図4. 同じコードのGENERIC形式とGIMPLE形式
たとえば、図5「プログラムの静的単一代入形式」のプログラムを取り上げてみましょう。左側はオリジナルのGIMPLEプログラムで、右側には、同じプログラムがSSA形式で示されています。
GIMPLE program SSA form 1 a = 3 a_1 = 3 2 b = 9 b_2 = 9 3 c = b++ / a c_3 = a_1 + b_2 4 b = b + 1 a_4 = b_2 + 21 5 d = a + c d_5 = a_4 + c_3 6 return d return d_5図5. プログラムの静的単一代入形式
すべての代入において、変数を修飾する新しいバージョン番号が生成されていることに注目してください。そして、式の中で変数が使用されるときは、常に、最新のバージョンが使用されています。従って、3行目で変数aを使用するときにはa_1を使用するように修正されていますが、5行目でaを使用するときには、a_1ではなく、a_4を使用するように修正されています。
では、なぜ、このことがオプチマイザにとって利点になるのでしょうか。たとえば、定数の伝搬と呼ばれる、非常によく使用される最適化について考えてみましょう。この最適化では、コンパイラは、定数値を使用して、コンパイル時にできるだけ多くの式を計算することを試みます。プログラムはSSA形式で表現されていて、かつ変数にはすべてバージョン番号が付けられているため、コンパイラは、簡単に、バージョン番号で指標付けされた配列を作成し、定数値を追跡できます。配列CSTがそのための配列だと仮定します。この場合、CST[3]の値は3で、CST[2]の値は9です。従って、3行目の命令文、c_3 = a_1 + b_2、を調べるときは、コンパイラはc_3は常に12に違いないと推定します(すなわち、CST[3]に12を保存します)。このようにしてすべての命令文を調べた後、次のような結果が得られます。
1 a_1 = 3 2 b_2 = 9 3 c_3 = 12 4 a_4 = 30 5 d_5 = 42 6 return 42
そして、今や、すべての定数を伝搬してしまったので、1行目から5行目までのすべての命令文がまったく何の意味がないことが分かります。このプログラムは単純に42を計算し、返しているだけです。
では、変数の最新のバージョンが何であるか決められないときには、どうなるのでしょう。実際のプログラムは、直線的に順番に実行されるコードであることはほとんどなく、制御の流れを変えるループや条件文を含みます。たとえば、次のプログラムでは、プログラムの実行時に条件文よる分岐がどうなるのか、コンパイラが判断することは不可能です。
x = foo () if (x > 10) a = 92 else a = 23 endif return a
プログラムをSSA形式に変換しようとするとき、コンパイラは変数aの2つのバージョンのどちらを使用すべきか判断できません。このあいまいさを解消するために、2つの矛盾するバージョンをマージした3番目のバージョンが生成されます。このマージ操作は、次のPHI関数として知られています。
x_1 = foo () if (x_1 > 10) a_2 = 92 else a_3 = 23 endif a_4 = PHI (a_2, a_3) return a_4
コンパイラは、a_2とa_3のどちらが返えされるのか前もって知ることができないため、それらの2つのバージョンをマージしてa_4を生成します。これは、a_4はa_2とa_3の中のどちらかではあるけれど、どちらであるかは分からない、ということをオプチマイザに示しています。
Tree SSAにより、GCCに高度な解析と最適化の実装を可能にする、新しい最適化のフレームワークが提供されます。最初の実装は、GCC 4.0のリリースにより、ご利用いただけます。このフレームワークは現在も開発中ですが、次のリリースでは、ポインタおよび配列の境界のチェック(-fmudflap)、Fortran 90のサポート、自動ベクトル化、種々の高度なループ変換と既存のほとんどのスカラ最適化パスなど、いくつかの新機能がご利用になれます。
すべての解析と変換の基礎を広く知られ公表されているアルゴリズムに置くことにより、当社はGCCの保守と新機能開発に関するレッドハットの能力の向上を目指しています。さらに、標準的な技術を使用していますので、GCCに必ずしも精通してはいないコンパイラコミュニティのグループが、外部から参加し易くなっています。
[2] L. Hendren, C. Donawa, M. Emami, G. Gao, Justiani, B. Sridharan, "Designing the McCAT Compiler Based on a Family of Structured Intermediate Representations", Proceedings of the 5th International Workshop on Languages and Compilers for Parallel Computing, Lecture Notes in Computer Science, no. 457, Springer-Verlag.
[3] Robert Morgan.Building an Optimizing Compiler, Butterworth-Heinemann, 1998.