1-8 コード生成3


最終回では、コード生成に関係する話題で、これまで取り上げられなかったものをひろって解説する。


ループ

if 文のコンパイルについては既に説明したが、Tiny C には while 文や for 文がない。

while 文のコンパイルは if 文のコンパイルとそれほど違いはなく、分岐がわずかに複雑になるだけである。

このプログラムは例えば次のようにコンパイルすればよい。

L1, L2 はラベルである。while 文の制御構造をそのまま分岐命令に置きかえていることがわかるだろう。

if 文の場合、分岐命令への置きかえ方はほとんど一通りであるが、while 文の場合はいくつかの置きかえ方が考えられる。上の例は、while 文の制御構造そのままで直観的だが、ループを回る度に分岐命令 jmp と je (ループを抜けるとき以外は分岐しない)を2つ実行しなければならない。一般に、分岐命令は演算命令に比べて遅いので、while 文を次のようにコンパイルするようにすれば、より高速な実行が期待できる。

分岐命令 jne は jump on not equal の意味で、0 (zero) と %edx のが等しくなければ L1 に分岐する。

上のようにコンパイルすると、一回のループごとに分岐命令 jne を一回しか実行しなくてよい。その分、ループの一回目に L2 へ分岐するための無条件分岐命令 jmp を実行しなければならないが、 これはループ全体につき 1 回だけなので、ループの回数が増えれば増えるほど影響は小さくなる。

while 文がコンパイルできれば、for 文のコンパイルも容易である。

このプログラムは例えば次のようにコンパイルすればよい。

基本は while 文のコンパイルと同様である。また while 文でしたような工夫をすれば、for 文もループ一回あたりの分岐命令の数を減らすことができる。


ループの最適化

ループ構造はプログラムのかなめであり、ここを工夫して高速に実行できるようにすれば、プログラム全体の実行時間の短縮に大きく貢献する。以下、有名な最適化の技法を解説する。

ループ内不変式のくくり出し

例えば下のような for 文を考えてほしい。

ループ中で i + k + 1 を計算しているが、このうち k の値はループの間中、変化しない。従って k + 1 の値も変化しないので、あらかじめループの外で k + 1 を計算しておいた方がよい。

このように k + 1 の値をあらかじめ計算して、変数 temp に代入し、ループ中では temp を参照するようにすれば、ループ一回ごとに加算命令をひとつ節約できる。temp の値を変数(メモリ)に退避せずに、適当なレジスタ上においておけば、より高速実行できる。

注:ここでは見やすくするために、ソースコード・レベルで不変式をくくりだしたが、実際のコンパイルでは、ソースコード・レベルの変換はおこなわず、上記のプログラムに対応する機械語命令を直接出力する。

Loop unrolling

分岐命令の数を減らす究極の方法は、ループそのものをなくしてしまうことである。例えば、

この場合、ループの回数は3と決まっているので、

と3回分のループを並べて(unrolling)しまえばよい。

ループの回数があらかじめわかっていなくても、部分的に unrolling することができる。

これを3回分 unrolling すると、

ループの内部では3回分まとめて実行するので、分岐命令の数は約 1/3 に減る。ただしループ終了後、3 の端数回分のループを実行しなければならない。

Tail recursion

最後にループ文のコンパイルではないが、ループに関連した重要な最適化技法を解説する。

C 言語などの手続型の言語ではあまり使われないが、Lisp などの関数型の言語では、繰り返し構造を表現するのに、関数の再帰呼出しがよく使われる。

関数呼出しは、分岐命令よりもさらに時間のかかる処理なので、上のような関数は、 tail recursion という技法を使ってループ構造に変換してコンパイルされる。

元の関数 f() 中の再帰呼出し f(i - 1, j + i) が終了したら、呼んだ側の関数 f() も終了するので、再帰呼出しのために新たにスタック・フレームを用意する必要 はない。


レジスタ割り当て

前回までに示したコンパイル方法では、結局レジスタは %edx と %cx しか使用していない。実際には他のレジスタも使用できるので、これらを使用しない手はない。理想的には、レジスタは使えるだけ使って、それでも足りない分だけをメモリに退避するようにした。

式の途中結果をメモリに退避せずに、空いているレジスタがあるときには、そのレジスタに退避するためのアルゴリズムを以下に示す。

例えば a = a + b - 3 をコンパイルするとしよう。

まず無限個のレジスタ (r1-rN) があると仮定して、プログラムをコンパイルする。レジスタ数は無限個なので、式の途中結果は全てレジスタに退避する。

何か新しい値を計算する度に、新しいレジスタを使っていることに注意。

次に同時に使われないレジスタは同じレジスタを使うようにして、全体として使用されるレジスタの個数を減らす。例えば、r1 は addl 実行後は使われないので、r3 を r1 で置きかえることができる。

このようなレジスタの置きかえを計算する方法として register coloring (彩色) が知られている。


関数引数の渡し方

C 言語の場合、関数引数は関数の呼出し側のスタック・フレームの最上部に積まれる。 したがって関数引数の個数に合わせてスタック・フレームの大きさを管理するのは、関数の呼出側の責任である。 たとえば Tiny C コンパイラでは、関数から戻ってきた直後に %esp の値を引数の大きさの分だけ減らさなければならなかった。

関数引数は、呼出し側のスタック・フレームの中ではなく、その上、つまり呼ばれた側の関数のスタック・フレームの一部となるように積む方法もありえる。

実際、Pascal の処理系では後者の方法をとる。 しかし後者の方法では、printf() のような可変長の引数をとる関数をコンパイルできない。 なぜなら呼ばれる側の関数のスタック・フレームの大きさは、引数の個数に依存するので、コンパイル時に引数の個数が不定であると、コンパイラがその関数のスタック・フレームの大きさを決められなくなってしまうからである。 このため C 言語では、前者の方法をとっている。

後者の方法の利点は、中から他の関数を呼ばない関数 (leaf 関数) の実装を高速化できる点である。 前者の方法では、leaf 関数を呼んだ場合も、呼出し終了後、常に呼出し側の関数が %esp の値を操作してしまう。 一方、後者の方法では、関数引数の個数に合わせてスタック・フレームの大きさを管理するのが呼ばれた側の関数なので、leaf 関数の場合は、スタック・フレームの管理を省略して高速化することができる。 スタック・フレームの管理のために %ebp, %esp を操作するのは、さらなる関数呼出しに備えるためであり、leaf 関数のようにそれ以上関数を呼び出さない場合は %ebp, %esp の値をそのままにして関数本体を実行することが可能である。

なお、以上の議論は x86 アーキテクチャの場合である。 対象となるプロセッサのアーキテクチャおよび calling convention によっては、ふたつの方法の利点・欠点は変化する。


課題 8

Tiny C コンパイラを拡張して、while 文、for 文が扱えるようにせよ。




目次へ戻る

Copyright (C) 1999-2000 Shigeru Chiba

Email: chiba@is.tsukuba.ac.jp