高速化: コンパイラについて


C言語のプログラムはたとえばfoo.cといった名前のファイルとして作成す る。gcc (GNU C compiler) でコンパイルして fooという名前の 実行形式ファイル(ユーザプログラム)を得るためには、シェルのコマンドライ ンで:

        gcc foo.c -o foo
と指示すればよい。得られたユーザプログラムを「-a 300」という 引数で実行するには
        ./foo -a 300
のように指示すればよい。最適化をコンパイラに指示するには:
        gcc -O2 foo.c -o foo
のようにする。(-O2の意味は、info や man などで調べること)

実際にはgccは、Cコンパイラ(プリプロセッサ付)、 アセンブラ リンカを用いて、Cプログラムのファイル→[Cコンパイラ]→ アセン ブリ言語プログラムのファイル→[アセンブラ]→オブジェクトファイル→[リン カ]→実行形式ファイルのようにして、ユーザプログラムを作成している。以下 では、この各段階を順にみていく。

C言語のプログラムfoo.c を(プリプロセッサに通した後)コンパイルして foo.sというファイル名のアセンブリ言語プログラムに翻訳するには:

        gcc -O2 foo.c -S
とする。出力される foo.sはテキストファイルであるので:
        less foo.s
などとすればアセンブリ言語のプログラムを読むことができる。

アセンブリ言語は、ユーザプログラムの実行する計算機システムのプロセッサの 命令セットを基にしてに定められているため、少なくともプロセッサの種類ごと に異なる(機種依存である)。本演習・実験で用いる計算機システムでは、Intel 386 プロセッサ(と浮動小数点コプロセッサ)の命令セットに基づくものになって いる。 実際のプロセッサは何世代も後継の Intel Core2 Duoで、Core2に 基づくものになっているが、命令セットは同じと考えてもよい。 また,命令セットが同じでもプロセッサの世代が異なれば,性能の引き出し方も 異なるので,Core2 向けに最適化するとよい。 (計算機室のLinux端末のgcc にもCore2 向けに最適化するためのオプション がある。-mtune=core2がないかinfo gcc 等で調べてみる) また、浮動小数点演算は後継で追加されている SSE3 等 を用いたほうがよいかもしれない. (計算機室のLinux端末のgcc にもそのためのオプションがある。)

        gcc foo.c -S
とすればコンパイラ最適化しない場合のアセンブリ言語プログラム foo.s が 得られるが, アセンブリ言語プログラムの出力ファイル名を指定し、かつ、最適化を指示する 場合は:
        gcc -O2 foo.c -S -o foo-O2.s
とすればfoo-O2.sというファイル名のアセンブリ言語の最適化されたプロ グラムに翻訳できる。ここで、foo.sfoo-O2.s は内容的には同じ計算を行うアセンブリ言語のプログラムであるが、 通常、foo-O2.s のほうが高速実行可能なプログラムになっている。 比較してみて、どのように最適化しているかを考えること。 最適化の程度が小さければ:
        diff -u foo.s foo-O2.s
として比較できるかもしれないが、 実際にはプログラムは大きく異なるはずである。

C言語でプログラムを書くことのほうが多いといえるが、場合によってはアセン ブリ言語でプログラムを書かなくてはならないこともある。その場合は、エディ タなどでfoo.sなどのプログラムを作成することになる。アセンブリ言語 のプログラムは、テキスト表記の機械語命令を書くtextセクション、初期値を持 つデータを書くdataセクション、初期値を持たないデータ領域確保するbssセク ションなどから構成される。(bssなどの名前はシステム依存であり、計算機室の gccでは少し異なる名前を用いているかもしれない)

アセンブリ言語プログラムのファイルから、アセンブラによりオブジェクトファ イルfoo.oを得るには:

        gcc foo.s -c
とする。出力ファイル名を指定したければ、これまで同様「-o 出力ファイ ル名」を指定すればよい。この段階では、テキスト形式からバイナリ形式へと 翻訳される。特にアセンブリ言語プログラムのtextセクションは、実際にプロセッ サが解釈実行できるバイナリ形式の機械語プログラムに翻訳される。なお、 foo.sの代わりに foo.cを指定すれば、コンパイルとアセンブルが行われ て、foo.o が得られる。翻訳結果は:
        od -t x1 foo.o | less
でダンプできるが、人が読むようなものではないといえる。 (ただし、objdumpコマンドで見ればもっとわかりやすい表示も得られる。) アセンブラによるアセンブルは基本的には1対1対応の素直な翻訳なので、 人が考えるときはアセンブリ言語プログラムで考えればほとんどの場合は 十分である。

複数のオブジェクトファイルをリンクすることで、実行形式が得られる。 foo.cbar.cに分割してプログラムを開発したとし、 foo.cから翻訳したfoo.obar.cから翻訳したbar.oを元に fooという名前の実行 形式ファイル(ユーザプログラム)を得るには:

        gcc foo.o bar.o -o foo
とすればよい。コマンドラインでは指定されていないが、実際には、 crt何とか.o といったスタートアップ用オブジェクトファ イルや標準ライブラリに含まれるサブルーチンとともにリンクして実行形式を得 ている。リンクに用いるファイルリストなどを見るにはgccとする代わり にgcc -v としてやるとよい。リンク時には、分割コンパイルなどの理由 で別のオブジェクトファイルであったために、最終的なアドレスが不明としてシ ンボルで参照していた部分を結合後の最終的な実行形式中の具体的なアドレスで 置き換える。(ただし、ダイナミックリンクの場合はシンボルが残った実行形式 となり、ユーザプログラムの実行時に必要に応じてリンクされる) これにより、 別のオブジェクトファイルが確保するデータに対してアクセスしたり、別のオブ ジェクトファイルが持つ機械語命令に制御を移動できるようになる。

最終的に得られた実行形式のユーザプログラムのことも機械語プログラムと呼ぶ ことがある。ユーザプログラムのtextセクションはプロセッサが解釈する機械語 命令から構成されているが、dataセクションなどのデータをプロセッサが解釈実 行することは滅多にない。ユーザプログラムのdataセクションやbssセクション についてはプロセッサに対する指示ではなく、むしろプログラムの実行を管理す るオペレーティングシステムに対して、textセクションの機械語プログラムの実 行開始前に必要なメモリ領域を確保・初期化するように指示していると考えるこ とができる。dataセクションやbssセクションで、データの初期値や領域確保を するためのアセンブリ言語上の指示は、機械語命令とは異なるためしばしば 擬似命令と呼ばれる。

さて、Cプログラムの作成時に、実行時の状態を考える場合は、

などを考えることになる。一方、翻訳後の実行形式のユーザプログラムの 実行時の状態を考える場合は: を考えることになる。レジスタの中でも、 スタックポインタ(sp)と呼ばれ るレジスタは特別な意味を持つことが多い。C言語から翻訳した場合は、ネスト して呼出し後どこから再開するか(リターンアドレス)の情報を保持したり、引数 や局所変数の値を保持したりするために、LIFO的にメモリ領域を確保・解放する 必要がある( スタック領域)。スタックポインタの値として、どこまでメモ リ領域が使われているかを覚えておくことができる。またいまどこの命令を実行 中であるかは、 プログラムカウンタ(pc)の値ということが多い。pc がレ ジスタとなっている命令セットもあるが、Intel 386の場合はレジスタではなく、 pc の値はcall命令でスタックにpushする形なら取り出すことができる。 また、スタックトップの値をret命令でpopしてcall 命令の直後から実行 を再開(pcにセット)できるようになっている。

上記のようなCプログラムレベル、あるいは、ユーザプログラムレベルの実行時 の状態はgdbという デバッガでみることができる。 gdbで、C レベル(ソースレベル)のデバッグをしたい場合は、gccとする代わりに gcc -g としてやるとよい。例えば:

        gcc -O2 foo.c -o foo
の代わりに、
        gcc -g -O2 foo.c -o foo
として、デバッグ情報付きのユーザプログラムを得る。デバッグオプション (-g)と、最適化オプション(-O2)は同時に指定できないコンパイラ も多いが、gcc の場合は同時に指定できるように頑張っている。こうして得た ユーザプログラムをデバッグするには
        gdb ./foo
とし、デバッグ対象プログラムを「-a 500」 という引数で実行開始するには gdbのプロンプトに対して:
        run -a 500
とする。gdb のプロンプトに対しては、ブレークポイント設定(b)、実行 継続(c)、 ステップ実行(s)などを指示することができる。 また、ブレークポイントやエラーにより 一時停止中のプログラムのソースレベルの実行時の状態は次のコマンドで調べられる: 同様に、ユーザプログラムレベルの実行時の状態は次のコマンドで調べられる: gdbはこれら以外にも豊富なコマンドと詳細なhelpを用意している。

実際に動作するのはユーザプログラムであるので、gdbは、ユーザプログラムレ ベルの実行時状態しか情報として取り出せないが、コンパイル時に付加したデバッ グ情報を元にソースレベルの実行時状態のイメージを仮想的に作り出しているこ とになる。場合によっては、ユーザプログラムレベルではソースレベルの情報 (変数の値など)が残っていないことがあるので注意が必要である。上記コマンド を各自試して、ソースレベルの実行時状態とユーザプログラムレベルの実行時状 態について感覚を養うこと。

コンパイラの仕事は、Cプログラムを実行形式ユーザプログラムに翻訳すること であった。高速なプログラムの実行のためには、コンパイラはできるだけ 効率の良い機械語コードを生成する必要がある。中でも重要なのは、できるだ け レジスタを利用して計算を進めることにある。 メモリアクセスは レジスタへのアクセスよりも時間がかかるのが一般的だからである。Cプログラ ムにおける引数や局所変数の値、あるいは部分式の中間結果の値などは、できる だけ(スタック領域などの)メモリに置かず、レジスタ上に保持するようにする ( レジスタ割付)。この他、コンパイラが行う最適化手法には次のものがあ る:

基本的な考え方としては: などとなっている。これらの最適化により、Cプログラムと、最適化されたユー ザプログラムの対応関係はかなり崩れることも多い。Cの関数単位では対応がと れることが多いが、関数呼出しを、関数本体で置き換えてしまう インライ ン展開という最適化を行うと関数単位の対応もとれなくなる。なお、ここでは 速度に関する最適化を考えているが、使用メモリ量を削減するという最適化も あり、インライン展開はこの点は不利である。

最適化手法は、プログラムの計算内容(意味)を変えない範囲で(安全に)高速化を 行うものなので、その適用には条件を伴う。場合によっては、プログラムの作成 者にとっては安全であることが自明な最適化がなされないことも多い。そのよう な場合は、Cプログラムを修正することで最適化されるようなることも多い。し かし、修正後のプログラムが読みにくく保守しにくいようになるのであれば、そ こには速度と間にトレードオフが存在することになる。本演習ではCプログラム を工夫して計算をより速く実行するにはどうしたらよいかを考えるが、レジスタ 割付や最適化手法を考慮することで達成できる部分もある。

場合によってはC 言語による表現では、意図したユーザプログラムの生成が難し いこともある。機種依存であるが、C言語ではasm文を用意し、Cの関数の 本体の文としてアセンブリ言語の命令を埋め込めるようにしてもよいことになっ ている。asm文をサポートしないコンパイラもあるが、gcc では拡張した asm文が提供されていて、意図どおりのユーザプログラムの生成を行いや すくなっている。


Masahiro Yasugi