最適化のすすめ - より速く、より小さく -

BKEYの開発コンセプトには以下のものがあります。

これはなぜかというと、ネットワークで配布することを前提にしていたためです。
気軽にダウンロードしていただくには、

ファイルサイズが小さくないとイケない。

ちなみに最近の市販アプリケーションはたいていが

な作りになっているのです。
そして、そのようなアプリケーションは私は大嫌いなのです。起動時に待たされるのが嫌い。
すぐに起動しないといらいらします。
ですから、すぐに起動する、というのもBKEYの目標でした。


開発環境の変化

最近の開発環境は、MFCだ、VCLだ、というわけで開発者に楽をさせるような構造になっています。
これ自体はいいことなのです、私も一応プログラマですから。楽はしたい。
しかし、このデメリットとして、アプリケーションが肥大化する、ということならどうか。ちょっちがんばって書けば起動時間が10分の1になったりするというのに、手を抜いているために起動時間が20秒もかかったりしているわけです。
手を抜いて作った物を客に出すということが、

プロのやることか?

こういう思いもありまして、いまいちMFCで他人に見せる物を作る気にならないのです。

ましてや、もともとBKEYはネットワークで配布することを前提としていましたから、サイズを小さくするということは、課せられた使命でもあったのです。MFCで作成すると、何もしなくてもウインドウを開くだけのプログラムでファイルが100KBになりますから。同じ内容のプログラムをMFC使わずに書き上げれば2.5KBで済むのです。

→[ただ、窓を開くだけのプログラム WINDOW.EXE]

実行ファイルサイズが大きくなることで有名なDelphiで同じようにただウインドウを生成するプログラムを書いてみました。

unit Unit1;

interface

uses
Forms;

type
TForm1 = class(TForm)
private
{ Private 宣言 }
public
{ Public 宣言 }
end;

var
Form1: TForm1;

implementation

{$R *.DFM}

end.

こんな感じですが、Delphi 3.0でメイクすると176,640バイトになりました。
パッケ−ジを使うという指定をすると9KBになります。ただし、この場合は外部のDLLも実行に必要となります。これだけのプログラムですと必要なのはVCL30.DPLのみで、これが1,269,760バイトあります。
実際にはこれだけ必要となります。
KERNEL32.DLL以外はVCL30.DPLが使用しているのです。ですから、起動時にはこれだけのファイルを読み込みます。
*がついているものは実行ファイルが直接ロードしているものです。他はロードしたDLLなどがロードしているものです。

ADVAPI32.DLL
COMCTL32.DLL
COMDLG32.DLL
GDI32.DLL
*KERNEL32.DLL
MPR.DLL
MSVCRT20.DLL
OLE32.DLL
OLEAUT32.DLL
OLEDLG.DLL
RPCRT4.DLL
USER32.DLL
*VCL30.DPL
WINNLS32.DLL
WINSPOOL.DRV

ランタイムなしの時にはこれだけ。ちょっとだけファイルが少なくなっています。

*ADVAPI32.DLL
*COMCTL32.DLL
*GDI32.DLL
*KERNEL32.DLL
MSVCRT20.DLL
*OLE32.DLL
OLEAUT32.DLL
RPCRT4.DLL
*USER32.DLL
WINNLS32.DLL

ちなみに、先ほどのサンプルだとこれだけのファイルを読み込みます。

ADVAPI32.DLL
GDI32.DLL
*KERNEL32.DLL
*USER32.DLL
WINNLS32.DLL

ファイルサイズとの戦い

ファイルサイズが小さいということは、実行時に実行ファイルが占有するメモリも少なくなります。プログラムというのはメモリにロードされていないと実行できません。つまり、大きなプログラムはそのままメモリを占有するのです。
実際には、さらに作業用のメモリなどを必要としますから同一視はできませんが、実行ファイルのロードと維持に必要なメモリの量は確実に増加します。
また、プログラムが大きければ、今その時の実行に必要ないプログラムはメモリが足りない時にはHDDにページアウトしてしまいます。このような場合、プログラムを実行する際には、実行するだけではなく、次に必要なプログラムをページイン(HDDからロード)、足りなくなったメモリを確保するため要らない部分をページアウト(HDDにセーブ)するという動作が発生します。こうなると全体のパフォーマンスも低下してしまうわけです。
ファイルを小さくすると、プログラム全体がキャッシュメモリに格納され、全体の速度を稼げることもあります。Windowsのような大きなOSでは可能性は多分ありませんが。
また、ファイルが小さいと起動が速くなります。というのは、起動するときには一度実行ファイルをロードするのですが、この時にファイルサイズが小さければ一瞬でロードが完了するからです。
ただ、この時にロードするファイルというのはDLLも含みます。MFCでDLLを使ってランタイムライブラリを外部に配置するようにリンクすると、実行ファイルそのものは小さくできます。
しかし、実際に起動するときにはMFCxx.DLLという巨大なファイルも同時に読み込みます。ですから結局実行ファイルが小さくても、ロードするファイルは大きくなってしまいます。
ですからBKEYでは外部のDLLはできるだけ使用しない方向で開発してあります。


より小さく

そこで私はBKEYに手を入れるにあたり、まずは実行ファイルのサイズを小さくすることから着手しました。
どのような手段に出たかというと、

共通化できるところは徹底的に共通化する

これです。
まず、頻繁に出てくるコードシーケンスをピックアップします。
3回出てくるようなら、よほどの理由でない限り共通にする候補として取り上げます。
2回でも大きいようなら共通化してしまいます。

BKEYの場合、結構多いのが

あと、頻繁に行う操作として

があります。
これらをまとめて関数としてしまい、どんどん折りたたんでいきます。

もう一つのアプローチとして、極力関数を呼び出さない、というのがあります。
Cでは関数を呼び出すときには次のような動作が行われます。

また、戻るときにはこの逆の動作を行います。

引数が多ければ多いほど、関数の呼び出しにはオーバーヘッドがかかります。ですから極力、関数は呼び出さないようにします。また、引数の多い関数は作成しないようにします。

さて、この2つは矛盾します。
ですからどこかでバランスを取る必要があります。
関数にまとめた場合の特徴は下のようになります。

つまり、これの収支がプラスになればいいわけです。
一般には、よほど大きな引数でなければ関数本体の実行コードの方が大きいですから畳み込む効果は充分にあります。
また、引数によるオーバーヘッドはコンパイラによっては無視できるレベルのものになります。一般に引数はスタックに積んで関数を呼び出すものが多いのですが、引数をレジスタ渡しできるコンパイラも多くあります。レジスタで渡すことで、引数が少ないものならオーバーヘッドを削減できます。

引数は少なく

ということです。

で、どのように引数を少なくしていくか、というところですが引数として渡さなくてもなんとかなる性質のものはグローバル変数にしてしまいます。BKEYの場合、インスタンスハンドルやウインドウハンドルがグローバル変数になっています。これらは、起動時に値を設定したあと、変更することがまずないからです。
引数として渡していれば、その関数がハンドルを使用していることが明確になりますからできればグローバル変数としてこっそり使うようなことはせずに明示的に渡すほうがモジュール的には美しくなります。
しかし、ソースコードを見せることが目的のプログラムではありません。他人の環境で動作させることが目的なのです。ですからモジュール的に美しくなろうがなんだろうが関係ありません、使う人にはなんのメリットもないことです。これでデメリットを受けるのは開発者側です。その辺はきちんと資料を用意してあればなんとでもなりますからためらう必要はありません。ざっくりとグローバル変数にします。
あと、ローカル変数だとパフォーマンスが落ちるもの、というのもグローバル変数にします。
フォントやブラシのようなGDIオブジェクトがそうです。これらはウインドウに描画するときにだけ必要なので描画関数内のローカル変数にしてしまいがちですが、フォントなどGDIハンドルの取得にはかなり時間がかかります。ですから、できるだけ値を保存しておいて必要なときにだけ再作成するようにします。
すると、ローカル変数では処理できませんからグローバル変数にします。
あと、全体の状態を示す変数もグローバル変数にします。BKEYだとウインドウの大きさなどが該当します。
ウインドウの大きさは、そのつどAPIで取得できるのですが関数を呼び出すということは先ほど書いた通り、なんらかのオ−バ−ヘッドを伴います。呼ばなくてすむなら呼ばずに済ませるのも手です。
ウインドウのサイズというのはウインドウハンドルとは違い、実行中にほいほい変化するものです。
ですから、必要なときにそのつどGetClientRect()などで取得したくなるのですが、それを避けます。大きさが変化したときにのみ大きさを取得して、それをプログラム中でキャッシュします。
Windowsではウインドウの大きさが変化したときにはWM_SIZEというメッセージをウインドウに通知します。このタイミングで大きさを覚えておき、他の関数ではこの大きさを使うのです。
ですから、この変数もグローバル変数とします。
コレにより、細かい処理を行う関数を呼び出したときでも引数を少なくすることができます。


ループでコードを減らす

初期化のプロセスにもこだわります。
たとえばINIファイルからの読み込みですが、フォントのなどは複数指定することが可能です。
そのために、複数回システムを呼び出すわけですが、INIファイルの読み込み関数というのはサイズ的にもオ−バ−ヘッドが結構あります。

GetPrivateProfileString(
セクション名,
キー名,
デフォルト値のアドレス,
読み込みバッファのアドレス,
読み込みバッファのサイズ,
INIファイル名
)
という関数なので、この関数を繰り返し呼ぶことになります。
しかし、フォントの設定を読み込んでいるときには、

セクション名,
デフォルト値のアドレス,
読み込みバッファのサイズ,
INIファイル名

は変化しないのです。

ですから、ここを折り畳んでしまいます。
具体的にはこの関数呼び出しを連続して記述するのではなく、ループにしてしまうのです。
こんな感じ。

for(i=0; i<4; i++){
    GetPrivateProfileString(
    セクション名,
    キー名[i],
    デフォルト値のアドレス,
    読み込みバッファ[i],
    sizeof(読み込みバッファ[0]),
    INIファイル名);
}

変化するキー名と読み込みバッファのアドレスは配列にしてインクリメントしながらループさせます。
フォント以外でも設定ファイルの読み込みはこのようにして折り畳んでしまいます。
ループを使うときのメリットとデメリットですが、

メリット
重複するコードを削減することができる
デメリット
継続条件のチェックが必要


というのがあります。

for(i=0; i<4; i++){
    func();
}

という場合、
func(); func(); func(); func();

と書いた場合に比べて、forで使用している i=0; の代入、 i<4の比較、条件分岐、i++をインクリメントというオ−バ−ヘッドが生じるわけです。
また、最近のCPUだとパイプラインを持っていて、次に実行する命令を予測しながら実行していきます。
forのような、条件分岐があるとこの分岐予測が乱れてしまい、速度が劣ることがあります。
速度を追求する際にはできるだけ条件分岐を行わないようにすることも重要です。


最適化

最適化の挙動についても考慮する必要があります。
コンパイラは生成コードに対して、さらに最適化という処理を施すことでよりよいコードを生成しようといます。
具体的には

というようなことをします。

変数のレジスタ割りつけ

メモリよりレジスタの方が高速にアクセスできることを利用するものです。
for(i=0; i<999; i++)というような記述があった場合、iは頻繁に参照されますからメモリに確保するよりレジスタに確保した方が効率が良くなる場合があります。

ループの展開

先ほど書いたように、条件分岐を減らすために行います。
for(i=0; i<4; i++)a[i]=i;
という式があった場合、コレを展開してしまい、
a[0]=0;
a[1]=1;
a[2]=2;
a[3]=3;
としてしまうような動作です。

変数領域の使い回し

関数の前半で使用しているが途中から参照もしていない、という変数があった場合、途中からその変数を削除してしまいます。
コレにより、メモリ効率を向上させ、変数をレジスタに割りつける可能性を向上させます。

定数式の除外

a=v*w*x*y*z;
b=v*w*x*y;
c=v*w*x;
という式を次のように展開して、演算回数を減らします。
int X=v*w*x;
a=X*y*z;
b=X*y;
c=X;

値が変化しない参照の除外

ループ内部の定数式をループの外に配置します。コレにより演算回数を減らします。

for(n=0; n<8; n++){
    a[n]=b*c*d;
}


という式は次のように展開されます。

int X=b*c*d;

for(n=0; n<8; n++){
    a[n]=X;
}

値の変化しない演算をループの外に配置してしまうのです。

コンパイラがこのような操作をするので、ソレを見越したコードを書く必要があります。
たとえば、先の定数式の除外ですが、ここで、
a=v*w*x*y*z;
b=v*w*x*y;
c=v*w*x;

ではなく

b=v*w*x*y;
a=v*w*x*y*z;
c=v*w*x;

と書いてあればコンパイラは

int X=v*w*x;
b=X*y;
a=b*z;
c=X;

と変換することがあります。

一般にコンパイラは式の評価順序まで変えるような最適化は行いませんので、このようなテクニックは有効です。
また、評価順序を変えてしまうコンパイラは意図に反したバグも生成することがありますので注意が必要です。


ifを減らせ

ifは条件分岐を行うための文です。一般にコレはジャンプ命令にコンパイルされます。
非常に基本的な制御文ですが、このようなときにはifを使うべきではありません。

もし Aが0ならBに1、そうでなければ Bに0 を代入。

これをifを使って書くとこのようになります。

if(A==0){
    B=1;
}else{
    B=0;
}

これがどのようにコンパイルされるかというと、たいてい次のようになります。

if(A==0)goto EQ
B=0;
goto ENDIF;
EQ:
B=1;
ENDIF:


このような文であれば三項演算子 ? を使うべきです。

B=(A==0) ? 1 : 0;

これはこのようにコンパイルされます。

x=1;
if(A==0)goto LABEL;
x=0;
LABEL:
B=x;

ifは条件分岐の文なのでどうしても分岐してしまうのですが、三項演算子の場合、ただの演算ですから分岐を前提としたコードを吐き出しません。さらにここで出てきたxはたいていレジスタを使用しますので更に効率がいいコードになります。


ダイエット

次に余計なものを取り込まないようにします。
たとえばCランタイムのstrcpy()やstrlen()など。
これらは、Windowsのシステムにすでに用意されています。ですからわざわざCランタイムライブラリを使用する必要性が高くありません。
CランタイムとWindowsの組み込みでは多少パフォーマンスが異なり、Windows組み込みの方が若干速度において劣ります。しかしその差は僅差であり、たいていの場合は無視できる範囲です。これが無視できないのであれば、もはやCランタイムライブラリを使用するべきではなくてアセンブラで自作した方がいいでしょう。
WATCOM C++で計測したところ、だいたい2倍の効率差が発生しました。
aからzまでの26文字をコピーする、というのを100000回繰り返したのですが

Cランタイムstrcpy()48ms
Win32APIlstrcpy()100ms

というところでした。(MMX Pentium 200MHz Windows 95 OSR2)
倍だと思うと差が大きく思われるでしょうが、実際のプログラム中でstrcpy()が消費している時間というのは、かなり少ないはずです。

次に、sprintf()とwsprintf()を比較してみました。


Cランタイムsprintf()383ms
Win32APIwsprintf()344ms


若干ながら、wspiintf()の方が高速です。ただし、これは処理系によってはwsprintf()よりも高速なsprintf()を用意しているでしょうし、wsprintf()は完全なsprintf()互換ではないことを考えると、wsprintf()の方が確実に速い、とはいえません。%pが使えないなど、若干wsprintf()の方が機能が少なかったはずです。
%pはANSI準拠ではありませんので、どーでもいいといえばどーでもいいけど。

さて、多少遅くなるものの余計なコードを抱えなくてもWindowsに処理させることでアプリケーションはより身軽になることができることはわかっていただけたかと思います。


そして、より速く

さて、次はより速く、を目的としてチューニングします。
先ほど、全体で使用するデータをグローバル変数にしてシステムから取得するオ−バ−ヘッドを削減する書き方を紹介しました。
まずはこの手法を更に進めていきます。
BKEYの場合、練習文字列(AからZまで)はあまり変化しません。変化するのはメニューからコマンドを選択した時のみです。
ですから、当然この時にだけINIファイルからロードしてプログラム中でキャッシュします。
同じく、歴代のアレ・ソレも、キャッシングします。この場合、複数起動したときに整合性がが取れなくなりますがそこは

仕様ということで済ませます。

表示するフォントの名前、これもキャッシュします。起動時にだけ読み込むようにして後はINIファイルを参照しません。
現在のモードもプログラム中で管理します。
たとえばミスをカウントするか、とかWAVファイルを再生するかどうか、という指定です。
その都度メニューにチェックマークがついているかどうかをシステムに問い合わせる方法がありますが、コレではシステム呼び出しのオ−バ−ヘッドが生じます。ですからこれもフラグを用意してキャッシュ。
あと、ウインドウの背景色と文字の色もありますがこれも起動時にシステムから取得しキャッシュします。システムカラーが変更されたときにはWM_SYSCOLORCHANGEDというメッセージが通知されますから、この時に反応して再度取り直します。そのほかではシステムカラーを取得しないようにします。
システムを呼び出すと、呼び出しにかかる実行時のオ−バ−ヘッドと、関数呼び出しのためのサイズのオ−バ−ヘッドが生じますから、できるだけ呼ばずに済ませたいものです。

描画も重要です。Windowsの描画は遅いというのが定説で、Windowsが描画タイミングを通知するッセージ、WM_PAINTもかなり優先順位が低く設定されているくらいです。ですから、描画もできるだけ最小限に抑えます。
そのためにダブルバッファを用意します。仮想画面を用意してそこに描画しておき、描画する必要があるときにはそこからコピーするだけにするのです。コピーは結構高速に行えます。また、実際の画面に描画するより、仮想画面に描画する方が100倍くらい高速に行えます。この数値はビデオカードにも依りますが、実際の画面への描画はかなり低速です。
しかし、いくら仮想画面とはいえ描画自体はかなり遅いですのでできるだけ回数を押さえます。
全体の描画はウインドウサイズが変更されたときのみ行います。キーが押されたときには結局キーの位置を表示したり、文字を次の文字に進めるなどの処理が入りますからこの時は普通に描画します。


BKEYはこのようにして、サイズを小さくしつつパフォーマンスを落とさないように実装してあります。
そのため、

バグすれすれの仕様

があったりします。


そなたの居場所はここではあるまい

[トップへ] [BKEYについて] [ランキング] [入手方法] [開発日記] [闇の書]

[mail]メールはこちらCopyrigiht © 1998 びい(@nifty ID:RXA01732)