最適化しやすいソースコードの書き方

コンパイラはコードサイズと実行速度の両方を最適化しようとします。このため、ソースコードに対して、多数の変換を繰り返し適用します。ほとんどの最適化は数学または論理演算の理論に基づきます。その他の最適化はヒューリスティック(経験則)に基づくもので、より良いコードを生成したり、他の最適化の余地を広げたりします。この記事では、どのようにソースコードを記述することが、コンパイラにとって最適化しやすいのかをご紹介します。

ユーザーが記述したソースコード自身が、どれだけ最適化を適用できるか決定します。ソースコードの小さな変更が、効率の良いコード生成に大きく影響することがあります。

以下ではソースコードを記述する上で留意すべき点をいくつか示します。始めに一点明らかにしますと、ソースコードをできるだけ短い行で記述するために、三項演算子やポストインクリメントやカンマ演算子を使用して副作用を詰め込んでも、コンパイラが効率の良いコードを生成するわけではありません。それはソースコードを複雑に、メンテナンスしづらくするだけです。複雑な式の途中に存在するポストインクリメントや代入は見落としやすいです。ソースコードは読みやすく記述しましょう。

ループの条件と最適化

この単純なループでどのような不具合が起こりえるでしょうか?

for (i = 0; i != n; ++i) 
{
a[i] = b[i];
}

不具合は起こらないでしょう。しかし、コンパイラの効率の良いコード生成に影響する点がいくつかあります。

配列のインデックスはポインタの型と同じであるべきです。

a[i]という記述は *(&a[0]+i*sizeof(a[0])を意味します。言葉で表現するなら、配列aの最初の要素を指すポインタからi番目を指すようにオフセットを加算する、という意味です。ポインタの計算のため、イデックスがポインタと同じ型の大きさを持つことが良いアイデアです。(ただし、__farポインタはポインタとインデックスのサイズが異なるので除く)インデックスの型がポインタの型よりサイズが小さい場合、インデックスはポインタに加算する前にキャストをしなければなりません。スタック領域(RAM)がコードサイズ(ROM)よりも貴重な場合、インデックスの型に小さなサイズの型を使用することに価値がある場合もありますが、しばしばコードサイズと実行速度の両方を犠牲にします。さらにループ内のキャストはいくつかのループ最適化を阻害します。

ループ条件も重要です。ループ最適化の多くは、繰り返しの回数がコンパイル時に計算可能である場合に、適用されます。残念ながら、インデックスの最終的な値から初期値を引いてループ毎の増分で割って求められるほど、単純とは限りません。iがunsigned char型、nがint型、そしてnが1000の場合、何が起こるでしょうか?変数iは1000に達する前にオーバーフローします。配列bから配列aに256要素を無限にコピーすることを望むプログラマは恐らくいないでしょうが、そうであってもコンパイラはプログラマの意図を見越すことはできません。コンパイラは最悪を想定しなければならず、あらかじめ繰り返しの回数が明らかでなければいくつかの最適化を適用できません。またループ条件として比較する値が変数である場合、関係演算子のうち<=および>=の使用を避けるべきです。ループ条件が i <= nの場合、nがその型の最大値となる可能性があり、この場合、コンパイラは無限ループの可能性があると仮定します。

変数の別名(エイリアシング)

グローバル変数は多くの場合、悪い慣習とされます。グローバル変数はプログラムのどこからでも書き換えられる可能性があり、プログラムのどの部分もそのグローバル変数に依存している可能性があるからです。結果として複雑な依存関係を生み、プログラムの理解とグローバル変数に影響される箇所の特定を困難にします。コンパイラにとって最適化しやすいソースか、という視点ではさらに状況は悪く、ポインタを介した代入の全てがグローバル変数の変更をもたらす可能性があるためです。ある変数が複数の方法でアクセスされる可能がある場合を別名(エイリアシング)と呼び、別名は最適化を一層困難にします。

char *buf
void clear_buf() 
{
int i;
for (i = 0; i < 128; ++i)
{
buf[i] = 0;
}
}

たとえプログラマがグローバル変数bufの指すアドレスが不変と知っていても、コンパイラは最悪を想定しなければならず、結果として変数bufは繰り返しの度にメモリからロードしなおされます。

変数bufが指すアドレスを引数として渡すことで、グローバル変数の別名を削除できます。

void clear_buf(char *buf)
{
int i;
for (i = 0; i < 128; ++i)
{
buf[i] = 0;
}
}

ポインタを使ったストアによってbufが書き換わらないよう小さな変更をした後、変数bufはループ内で不変となり、繰り返すたびにロードする代わりに、ループに入る直前に一度ロードするのみに最適化できます。

グローバル変数は互いに呼び出し関係のないソースコード間において、情報を渡すのに便利です。その場合、値を渡すだけにしてください。何かしらの計算、特にポインタ演算をするのであれば、ローカル変数を使うことを推奨します。

ポストインクリメントおよびポストデクリメントの使用を控えて最適化しやすく

以下の説明は、ポストインクリメントとポストデクリメントに共通します。C言語におけるポストインクリメントとは、「後置++演算子の結果はオペランドの値です。結果が得られた後、オペランドの値は加算されます。」という意味です。マイクロコントローラがロードまたはストアの後にポインタをインクリメントするアドレッシングモードを持つことが一般的な一方で、ポインタ以外を同様の効率で処理できるものはほとんどありません。C言語の標準に従い、コンパイラはインクリメントの前に変数の値をテンポラリ変数にコピーする必要があるかもしれません。分かりやすいコードのため、インクリメントは式の外に置くことができます。

例えば、

foo = a[i++];

上記の記述は以下のように実行されます。

foo = a[i];
i = i + 1;

しかし、whileループの条件判定の一部としてポストインクリメントが行われるとき、何が起きるでしょう?条件判定の後にはインクリメントを挿入できる場所がないので、インクリメントは条件判定の前に行わなければなりません。

ある単純なループは、

i = 0;
while (a[i++] != 0)
{
...
}

以下いずれかのように実行されなければなりません。

loop: 
temp = i; /* save the value of the operand */
i = temp + 1; /* increment the operand */
if (a[temp] == 0) /* use the saved value */
goto no_loop;
...
goto loop;
no_loop:
loop: 
temp = a[i]; /* use the value of the operand */
i = i + 1; /* increment the operand */
if (temp == 0)
goto no_loop;
...
goto loop;
no_loop:

もしループの実行後の変数iの値が重要でないのであれば、インクリメントはループの本体に置いたほうが良いです。ほぼ等価な以下のループはテンポラリ変数なしで実行できます。

i = 0; 
while (a[i] != 0)
{
++i;
...
}
loop:
if (a[i] == 0)
goto no_loop;
i = i + 1;
...
goto loop;
no_loop:

最適化コンパイラの開発者はポストインクリメントが招く複雑性を熟知しています。たとえ前述のパターンを認識し、テンポラリ変数を可能な限り減らすために最善を尽くしても、特にループの条件が例よりも複雑な場合など、効率の良いコード生成が困難なケースが常にあります。このため、複雑な式を複数のシンプルな式に分割することが望ましいです(例えば先ほどのループ条件のように、条件判定とインクリメントを分割)。

C++ではプリインクリメントとポストインクリメントの選択がより重要です。どちらの演算子++、--も前置および後置でオーバーロードすることができます。このため、演算子がクラスオブジェクトに対してオーバーロードされた場合、基本型の演算と同様に振る舞う必要があるわけではないからです。しかし、基本型にできるだけ近いふるまいに保つことがきっと良いアイデアです。そうすることでクラスは直感的にインクリメント、デクリメントできます。例えばiteratorは、通常、前置と後置の両方の++演算子・--演算子を持ちます。

基本型に対する前置++と同様にふるまうことはオブジェクトを変更し、そして変更されたオブジェクトの参照を戻します。では基本型の後置++と同様にふるまう場合はどうでしょう?覚えていますか?「後置++演算子の結果はオペランドの値です。結果が得られた後、オペランドの値は加算されます。」です。前述のコードと同様に、operator++(int)の実装者は、元のオブジェクトをコピーし、元のオブジェクトを変更し、コピーしたオブジェクトを値で返さなければなりません。このコピーがoperator++(int)をoperator++()よりも高コストにします。

基本型の変数iに対するi++の結果が使われないのであれば、最適化は不要なコピーを削除します。しかし、最適化はオーバーロードされた演算子を別の演算子の呼び出しに変えることはできません。もし癖で++iの代わりにi++を記述してしまうのなら、よりコストの大きな演算を行うことになります。

ここまでポストインクリメントの使用について反対してきましたが、ポストインクリメントが便利なことも確かにあります。変数をポストインクリメントすることが正にしたいことなのであれば、そのままポストインクリメントを使ってください。

しかし、ただ式を分けて書くのを避けるためだけに、式の中でポストインクリメントはしないでください。不必要なポストインクリメントを足すときはいつも(例えばループ条件、if文、switch文、三項演算子、または関数の引数など)、コンパイラは大きくて遅いコードを生成せざるを得ない場合があります。覚えることが多すぎますか?であれば新しい習慣を今日から始めましょう!インクリメントの結果を使わないのであれば、++iをi++の代わりに書きましょう。結果を使うのであれば、インクリメントは次の行にわけてかくことができるかどうか、自問してみてください。