Microsoft Visual C++ 6.0 による最適化コードの開発
Martin Heller
概要 ここでは、Microsoft® Visual C++® のコンパイラでソース コードの速度とサイズを最適化する方法を示し、なぜコードのサイズが問題になるかについて論じます。コード生成、Visual C++ でサポートされている最適化スイッチと プラグマ ステートメント、プロジェクト単位で最適なスイッチを選択する方法、関数レベルでスイッチをオーバーライドする方法についても説明します。
目次
はじめに
コード最適化の原則
速度の最適化
サイズの最適化
タイミングとプロファイリング
Microsoft Visual C++ での最適化
コンパイラ最適化
最適化設定のチューニング
遅延ロード DLL
ランタイム ライブラリとシステム API オプション
Visual C++ を使ったタイミングとプロファイリング
要約
Microsoft Visual C++ 開発システムは、高速の小さいプログラムを作成する際に、プログラマが好んで選択する言語です。Visual C++ 6.0 は、最も広く使われている 32 ビット Microsoft Windows® 用のコンパイラであり、高速なプログラムの作成に役立つ多くの機能を備えています。この記事では、最適化設定、プロファイリング、遅延ロードの各機能を紹介し、これらの機能を効率的に使う方法を説明します。
この記事では、読者が C++ を熟知していることを前提にしています。
プログラマにとって理想的な世界とは、プログラマの指定した仕様に従ってソフトウェアが自分自身を設計し、自動的に自分のサイズを縮小してメモリ消費量を最小限に抑え、直ちにコンパイルが完了し、完成したソフトウェアが常に最高速で動作する世界です。しかし、現実には、開発者は、何らかのプログラム言語を使ってプログラムを書く必要があります。最小にして最高速のコードを書くことが絶対に必要な場合は、プログラマがアセンブリ言語を使えばよいのですが、実行速度が最優先されるコードを開発する場合を除いて、アセンブリ言語は労働集約性があまりにも高すぎます。
ほとんどのプログラマは、高級言語を使って大半のコーディングを行い、それをアセンブリ言語にコンパイルして、実行可能プログラムのイメージにリンクします。開発の段階では、プログラマは、デバッグの速度と容易性に非常に敏感になります。稼働用のコードをリリースする段階になると、実行時の速度とサイズが最重要課題になります。この記事では、終始、稼働用コードの実行時のサイズと速度の説明に専念します。
コードの実行速度を速くする方法はたくさんあります。多くの場合、プログラムの速度を左右する要因は、コンパイラによる最適化というよりは、使用されるアルゴリズムにあります。たとえば、HeapSort アルゴリズムを使って書かれたソート プログラムは、BubbleSort を中心とするループを実行しますが、これは、リスト内のアイテムの数が多いときに、HeapSort アルゴリズムが実行する比較の回数がずっと少なくて済むからです。
配列構造を使った検索アルゴリズムは、通常、動的に割り当てられるリンクリストを使った検索アルゴリズムより速く動作しますが、これは、配列がメモリに連続的に格納され、仮想メモリの数ページにまたがって存在する可能性が高く、とりわけ RAM 上に存在することが多いからです。一方、動的に割り当てられるリンクリストは、メモリ内に分散して存在することが多く、リンクリストの検索では、ページ違反が発生する可能性が高くなります。しかし、順番に並べられたリンクリストに要素を追加したり、リンクリストから要素を削除する操作は、通常、それと同等の配列を使った操作より速くなります。これは、リンクリストではほかのリスト要素を動かす必要がないからです。
コードを速く実行する意味がないこともあります。たとえば、キー操作に 1/10 秒で反応するユーザー インターフェイス コードは、キー操作に 1 ミリ秒で反応するユーザー インターフェイス コードと区別がつきません。これは、ユーザーのタイプ速度によってスループットが限定されるからです。このような場合は、ASWATSS (All Systems Wait At The Same Speed:すべてのシステムは同じ速度で待機する) という法則が当てはまります。コードを速くする必要がないときは、コードをできるだけ小さくすることを考えた方がよいでしょう。
一方、コード、特に頻繁に実行されるコードやタイトなループの中で実行されるコードの実行速度を速くすることは、多くの場合、重要です。仕事の目的に最適なデータ構造とアルゴリズムを選択し、最もクリーンな C++ コードを書いた後は、コンパイラが、それをできる限り高速な、または小さい、あるいはその両方を兼ね備えた実行プログラムに変換してくれることを望みます。
速度の最適化
コードの速度を最適化する標準的な方法はたくさんあります。コンパイラについて書かれた本なら、どんな本にでも、その方法が記載されています。Visual C++ について詳細に論じる前に、いくつかの方法について説明しておいた方がよいでしょう。これらの速度最適化手法の一部は、プログラムのサイズの最小化も行いますが、サイズを犠牲にして速度を優先させるものもあります。多くの場合、グローバル コンパイラ最適化では、生成された命令の実行順序を変更するため、最適化されたコンパイル済みのプログラムをソース コード レベルでデバッグすることは困難です。
共通部分式除去では、式の中の繰り返し計算される部分を探し、インストラクション カウントが少なくなるように式を書き換えます。たとえば、次のような式があるとします。
Y=a*(y1-avg)+b*(y1-avg)+c*(y1-avg);
コンパイラは、変数内で繰り返される中間結果を次のように省略することによって、インストラクション カウントを削減することができます。
Temp=(y1-avg);
Y=a*Temp+b*Temp+c*Temp;
このコードの 2 番目のパスは、以下のように省略できます。
Temp=(y1-avg);
Y=Temp*(a+b+c);
この最適化では、3 個の減算、3 個の乗算、2 個の加算が、1 個の減算、1 個の乗算、2 個の加算に変換されました。最適化されたコードは、確実に非最適化コードより高速になると同時に、より小さくもなるでしょう。
コピー伝搬と不要ストア除去では、使われていない中間変数を計算ストリームから除去し、サイズと速度の両方を改善します。これにより、最適化されて除去された変数に対応するロード、ストア、メモリ ロケーションを除去することができます。C++ でよく見られる構造体のコピーを返す場合のように、構造全体を除去すると、大幅な改善を図ることができます。
int foo(struct S sp) {
struct S sa=sp; return sa.i }
コピー伝搬では、これが次のように変換されます。
int foo(struct S sp) {
return sp.i }
多くの場合、何回も繰り返されるループ内のコードの中には、ループの実行中に値が変わらないものがあります。この種のループ不変式は、ループの実行前に 1 回だけ計算されるようにすることができます。一般的に、ループ不変式を削除するか、ループ外に移動することにより、サイズをあまり変えることなく、速度を改善することができます。たとえば、不変式のループ外移動によって、次のループの中から演算を削除できます。
for (i=0;i<1000;i++) {
A[i] = a + b; }
ループ外移動を行うと、次のようになります。
t = a+b;
for (i=0;i<1000;i++) {
A[i] = t; }
これによって、1 つの一時変数という犠牲を払うだけで、999 回の加算を省略することができます。しかも一時変数は、ほとんどの場合、レジスタに格納されます。
実際、一般的にレジスタへのアクセスの方がメモリへのアクセスより高速であるため、C および C++ には、頻繁に使われる、CPU レジスタに格納した方がよい変数を指定するときにプログラマが使用できるレジスタ キーワードがあります。コンパイラは、明示的な指定がなくても、レジスタ割り当てを最適化することができますが、プログラマよりコンパイラの方がより効率よく最適化を行えるため、Visual C++ を始めとする多くのコンパイラはレジスタ キーワードを無視します。
一般的に、レジスタの使用の最適化は、速度とサイズの両方を改善します。汎用レジスタをほとんど持っていない Intel® x86 ファミリなどのプロセッサでは、これが最も重要な速度とサイズの最適化手法の 1 つになります。
非常にタイトな内部ループでは、CPU 時間の大半がカウンタ演算と条件分岐に費やされることがあります。ループを展開すると、コンパイラがループを直線的なコードに変換できるため、サイズを犠牲にして速度を改善することができます。x86 プロセッサでは、同じクロック数で実行されるシングルバイト操作をマルチバイト操作で置き換えることができれば、ループの展開によって速度が飛躍的に向上します。コード LODSB の代わりに LODSW を使って、1 回のメモリ アクセスごとに 2 バイトを読み込む文字処理ループが具体的な例です。
インライン関数展開でもサイズを犠牲にして速度を改善することができます。コンパイラは、関数呼び出し (およびそれに伴うパラメータ渡し、スタックの保持、ジャンプによるオーバーヘッド) を関数本体のインライン バージョンで置き換えることができます。さらに、インライン展開を共通部分式除去やループ不変式除去などのグローバル最適化と組み合わせれば、相乗効果によって、速度とサイズの両方を飛躍的に改善できることがあります。
最近のパイプライン化されたプロセッサでは、命令順序が正しければアドレスをプリフェッチでき、正しくなければプリフェッチできないため、命令順序が実行速度に大きい影響を与えます。Michael Abrash は、『Zen of Code Optimization』(Coriolis, 1994) の中で、アセンブリ言語で書かれた簡単な 3 つの命令から構成される内部ループの速度を 80486 プロセッサ上で 2 倍にする方法を論じています。この場合は、最初の 2 つの命令の順序を入れ替えて、複雑な 3 番目の命令を 2 つの簡単な命令に分割することによって目的を達成しています。
Intel Pentium などのデュアル実行パイプラインを備えたプロセッサでは、最適化の操作がさらに複雑になります。Pentium の両方の実行パイプラインを満たし、同時に実行させるコードは、そうでないコードの最大 2 倍の速度で実行することができます。Pentium では、一般的に、簡単な命令のストリームの方が少数の複雑な命令より実行速度が速くなりますが、プログラムのサイズが大きくなるという犠牲が伴います。Abrash が指摘しているように、Pentium の最適化では、速度とコードのサイズの両方が同時に 2 倍になることがよくあります。
2 つの長い実行パイプラインを備えた Pentium Pro では、Pentium や 486 に比べると、命令スケジューリングの重要性はずっと低くなりますが、分岐予測がより重要になります。Pentium Pro では、「動的実行」が可能です。これは、12 段の命令プリフェッチ パイプラインに命令を順不同に取り込むことができることを意味しています。Pentium Pro は大容量のオンチップのメモリ キャッシュも備えています。ただし、命令プリフェッチは、プロセッサが命令ストリームを正しく予測できるときにのみ機能します。実行がどこに分岐するかの予測をプロセッサが誤った場合は、プリフェッチされたパイプラインをフラッシュし、正しい分岐から再度読み込む必要があります。
サイズの最適化
この記事では、一貫して、コードの速度と共にコードのサイズの重要性も強調していますが、なぜだろうかと疑問に思う読者もいるかもしれません。128 MB の RAM が当たり前の構成になっている現在、誰がサイズのことを気にするでしょうか。
実際、特に Windows のようなマルチタスク オペレーティング システムでは、コードのサイズが実行速度に多大な影響を及ぼすことがあります。サイズが大きいことが直接の原因となって、コードがスローダウンする状況が 2 つあります。それは、キャッシュ ミスとページ違反です。キャッシュ ミスが起きると、メモリ ロケーションが CPU キャッシュにないときに若干の遅延が生じます。ページ違反が起きると、仮想メモリ ロケーションが物理メモリにないためにディスクからフェッチする必要がある場合に、大幅な遅延が生じます。ワーキング セット (プログラムを実行するためにメモリに存在する必要があるコードとデータの合計) が大きいと、キャッシュ ミスやページ違反が発生する率が高くなります。
80486 マシンでは、CPU メモリ内の一次キャッシュから 1 バイトを読み取るのに 1 サイクルを要しますが、キャッシュ内にない 1 バイトを RAM から読み出す (キャッシュ ミス) ときは、それより 13 サイクルも余計に時間がかかります。二次キャッシュがあって、その中に要求されたバイトがある場合は、読み取りに要する時間がこれら両極端の場合の中間になります。
80486 には、コードとデータ両用の 8 KB のキャッシュが 1 つあります。内部ループのコードとデータがすべてキャッシュに収まれば、ループは最高速で実行できます。収まらなければ、最高速では実行できません。
Pentium、MMX 付き Pentium、Pentium Pro、Pentium II、Celeron、Xeon、Pentium III を搭載したマシンでは、別々の命令キャッシュとデータ キャッシュが CPU チップに内蔵されています。ループのコードがすべて CPU の命令キャッシュに収まれば、コードのフェッチで遅延が生じないため、キャッシュに収まらないときに比べて、コードの実行速度がずっと速くなります。同様に、すべての作業メモリがデータ キャッシュに収まれば、データのフェッチで遅延が生じないため、プログラムの実行速度がずっと速くなります。
さらに、Pentium II のマザーボードに 512 KB 実装されていることが多い二次キャッシュは、通常、コードとデータで共有されます。一次キャッシュの容量が十分でない状況では、作業用のコードとデータの総計が二次キャッシュに収まれば、キャッシュに収まらないときに比べて、プログラムがずっと速く動作します。
RAM 上に存在しないプログラムの一部をハード ディスク上の仮想メモリ ページ ファイルから取り出す必要が生じたときに、ページ違反が発生します。ディスクは RAM に比べると非常に遅いため、ページ違反が 1 回発生しただけで、大幅なスローダウンが生じます。
ここで、標準的な 400 MHz の Pentium II デスクトップ マシンを想定してみましょう。クロック サイクルは 2.5 ナノ秒、つまり 2.5×10-9 秒であり、一次 CPU キャッシュからの読み取りには 1 サイクルを要します。読み取りがプリフェッチできて、命令をほかの命令とパラレルに実行できれば、読み取りはさらに速くなります。一次キャッシュをミスしたときの遅延は、最低 3 サイクル、つまり 7.5 ナノ秒であり、二次キャッシュをミスしたときの遅延は、最低 14 サイクル、つまり 35 ナノ秒です。二次キャッシュをミスしたときの最大の遅延は、標準的な数値が 60 ナノ秒であるメモリ アクセス時間そのものですが、これは 24 サイクルに相当します。
ページ違反が起きると、どうなるでしょうか。この場合は、ハード ディスクからコードを取り出す必要がありますが、それには最悪の場合、10 ミリ秒、つまり 10-2 秒以上の時間がかかります。これは 10,000,000 ナノ秒であり、4,000,000 サイクルに相当します。幸い、最悪のケースはめったに起きません。ほとんどのディスク ドライブはキャッシュ用の RAM を内蔵しており、Windows も RAM をディスク キャッシュ専用に割り当てます。これら 2 つのキャッシュにより、通常、ディスク スループットと実効アクセス時間は約 50 倍に向上します。
したがって、典型的な「ソフトな」ページ違反は、80,000 CPU サイクルに相当する 200 マイクロ秒の犠牲をもたらすことになります。時間をなじみ深い単位に置き換えてみると、一次 CPU キャッシュから 1 バイト読み込むのに 1 秒かかるとすれば、ページ違反の処理には、ほとんど一日かかることになります。
では、どんなときにページ違反が発生しやすいのでしょうか。1 つの例は、ディスク キャッシュを含むオペレーティング システムのすべてのコードとデータと、ユーザーが実行しているすべてのプログラムの総計がメモリに収まらない場合です。別の見方をすれば、システムを共有しているすべてのプログラムは、メモリを節約するように心がける必要があります。メモリを大食いするプログラムが 1 つあるだけで、すべてのプログラムがページ違反に見舞われ、システム全体の速度が著しく低下します。
だからといって、すべてのプログラムをなにがなんでも小さくしなければならないわけではありません。CPU 集約型の内部ループの速度を最適化することは、完全に理に適っています。ただし、ユーザー インターフェイスやその他の非 CPU 集約型のコードの速度を、サイズを犠牲にして最適化するべきではありません。
なぜコードを小さくする必要があるかがわかったところで、サイズの最適化の話をしましょう。前に、共通部分式除去を速度最適化の手法として説明しました。これは、通常、サイズ最適化にも使われます。
不要コード除去は、純粋なサイズ最適化です。多くの場合、コンパイラには、特定のコード分岐や関数がまったく呼び出されないことがわかります。役に立たないコードを出力せずに抑制することによって、容量を節約することができます。もちろん、コンパイラはエクスポート関数に対してこれを行うことはできません。
前に説明したように、Pentium マシンでは、多数の簡単な命令のストリームの方が、同じ目的を果たす少数の複雑な命令のストリームより実行速度が速いことがよくあります。前に説明した例では、速度とサイズの両方が 2 倍になりました。コンパイラに対して速度よりサイズを優先させるように指示すれば、特定の C++ ステートメントに対応する最高速の命令ストリームではなく、最小の命令ストリームが生成されることが保証されます。ほとんどの場合、Windows のプログラムでは、コードの大半についてサイズを優先させ、コード内の「ホットスポット」についてだけ速度を優先させるのが最善策です。
あまり呼び出されることがないコード分岐が含まれているプログラムはたくさんあります。たとえば、ワープロソフトでは、印刷の要求は 1 日に 1 回か 2 回あるだけですが、編集の要求は継続的にあります。印刷コードは、特にバックグラウンドで書式設定や改ページ位置の修正を行うスレッドを実行する場合は、サイズが大きくなりがちです。プログラムのサイズを抑えて、読み込み時間を短縮するために、プログラマは、必要なときだけ印刷コードを読み込むようにすることができます。
動的読み込みを実装する方法はいくつかあります。上記の例では、メインの編集プログラムで別の印刷プログラムを生成することができます。また、印刷ライブラリを動的に読み込み、そのライブラリの関数を呼び出し、印刷が完了したときにライブラリを解放することもできます。Visual C++ には、これを簡単に行うための方法があります。
タイミングとプロファイリング
「ホットスポット」の話が前に出てきましたが、ホットスポットの定義やホットスポットの探し方については、まだ説明していません。ホットスポットとは、プログラムの速度を低下させるほどの多大なサイクルや時間を消費する関数またはコード セクションのことです。これは化学でいう「律速段階」と同じものです。CPU ホットスポットは、多くの場合、プログラマ自身が書いたコードの内部ループの中にあります。また、サードパーティのライブラリ コードの中でホットスポットが見つかることもあります。まれに、ホットスポットがコンパイラのランタイム ライブラリ関数にあることも、ごくまれにはシステム API 関数にあることもあります。I/O にバインドされた関数は、I/O ホットスポットや I/O ボトルネックの影響を受け、その場合は、CPU にわずかの負荷しかかかっていないにもかかわらず、プログラムの速度が低下します。
プロファイリングを行うことによって、プログラムのホットスポットを見つけることができます。プロファイリングは、最も頻繁に呼び出される関数、最も多くのクロックと CPU 時間を消費する関数、コードの個々の行が呼び出される頻度などの情報を教えてくれます。
ホットスポットを見つけたら、関数またはコード セクションの所要時間を調べることができます。この場合は、通常、測定精度を向上させるために、ループ内部で測定して実行時間を長くします。コンパイラによって生成されるアセンブリ言語を見て、クロックをカウントすることにより、CPU ホットスポットの所要時間を計算することもできますが、動的実行を行う Pentium II などのプロセッサでは、クロックのカウントが非常に難しくなります。C や C++ のソース コードを見て、パフォーマンスに関するだいたいの見当をつけることはできますが、コンパイラがオブジェクト コードを生成し、最適化するときに、ソース コードをどうするかがよくわかっていないと、正確な判断は下せません。
以上のことを頭に入れて、話題を Visual C++ 開発システムに転じ、小さい具体例を検討します。ここでは、コンパイラのコード生成最適化、遅延ロード ヘルパおよびランタイム ライブラリとシステム API が関係する特殊なケースについて解説します。
コンパイラ最適化
Visual C++ 開発システムでは、今までに説明したほとんどすべての一般的なコード最適化とそれ以上の多くの最適化を実行できます。コンパイラ スイッチと プラグマ ステートメントを使って、コードに適用される最適化の種類を制御することができます。
Visual C++ 開発システムでは、コピー伝搬、不要ストア除去、共通部分式除去、レジスタ割り当て、関数インライン展開、ループ最適化、フロー グラフ最適化、ピープホール最適化、スケジューリング、スタック パッキングを実行できます。これらの手法の大半については、既に全般的な観点から説明しました。Visual C++ では、ブロック メモリ移動など、いくつかの小さい特殊なケースではループを展開することがありますが、通常はループの展開を行いません。
不変式のループ外移動のほか、Visual C++ では、演算強度の軽減およびインダクション変数の除去を実行できます。たとえば、次のコードは
for(i=0;i<10;i++) {
A[i*4]=5;
}
以下のようになります。
for(t=0;t<40;t+=4) {
A[t]=5;
}
このコードでは、不経済な乗算が経済的な加算で置き換えられています。
フロー グラフ最適化では、if-then-else ブロック内の共通する命令を並べ替えます。テール スプリッティングでは、共通する return ステートメントをブロックに挿入することによって、ジャンプを除去します。したがって、次のコードは
if(x) {
b = 1;
} else {
b = 2;
}
return b;
以下のようになります。
if(x) {
b = 1;
return b;
} else {
b = 2;
return b;
}
これは速度最適化です。
一方、テール マージングでは、重複している命令をブロックから引き出すサイズ最適化を行います。テール マージングを行うと、次のコードは
if(x) {
bar()
b = 1;
} else {
foo()
b = 1;
}
以下のようになります。
if(x) {
bar()
} else {
foo()
}
b = 1;
スタック パッキングでは、同時にスコープ内に存在しないオブジェクトのスタック領域を再利用します。その変形で、関数パラメータと関数のローカル変数にスタックを共有させて、可能であればスタック フレームの設定を除去すると同時に、使用メモリを削減するものもあります。
フレーム ポインタ省略では、コンパイルされたコードに、EBP レジスタでなく、ESP レジスタ (Intel の CPU の場合) を使ってローカル変数とパラメータにアクセスさせます。これによって、フレームの設定とクリーンアップのオーバーヘッドがなくなり、EBP を汎用レジスタとして使えるようになりますが、コードのサイズは犠牲になります。ESP の参照命令は、EBP の参照より大きくなります。フレーム ポインタ省略は、通常、全体的にはうまく動作します。Visual C++ では、フレーム ポインタ省略が有効になっており、コンパイラの発見的ルールがフレーム ポインタ省略を安全で使用価値ありと判断すれば、フレーム ポインタ省略を使用します。
ピープホール最適化は、オブジェクト コード レベルで機能し、最初に生成された命令シーケンスをより高速で小さいシーケンスに置き換えます。たとえば、レジスタ最適化の後、ソース コード x=0 が次のような Intel の命令を生成したとします。
mov eax,0
ピープホール最適化は、これをより小さい次の命令に変更します。
xor eax,eax
その他のピープホール最適化では、既に説明したように、サイズを犠牲にして速度を優先させるか、速度を犠牲にしてサイズを優先させます。また、プロセッサによって、効果の度合いが違います。スケジューリング最適化 (メモリ アクセスを分散させることによってメモリ プリフェッチを可能にし、パラレルで実行できる命令をペアリングする手法) も CPU への依存度が高くなります。
コンパイラ スイッチ
コンパイラ スイッチを使って Visual C++ のコード生成と最適化を制御できます。開発環境のプロジェクト設定ダイアログでスイッチを設定できます。コード生成に影響を及ぼすスイッチは先頭に /G が付いており、最適化を制御するスイッチは、先頭に /O が付いています。
Intel バージョンの Visual C++ では、Pentium CPU (/G5)、Pentium Pro CPU (/G6)、または Pentium と Pentium Pro 最適化オプション (/GB、デフォルト) の「ブレンド」をターゲットにすることができます。Pentium II CPU と Celeron CPU は、Pentium Pro と同じパイプライン特性を持っています。一般的に、デフォルトの /GB スイッチは、現在最も一般的なプロセッサをターゲットにしているので、好結果を生み出します。
Visual C++ 6.0 の /GB は、実際に最適化をブレンドするわけではなく、/G5 へのマッピングを行っています。コンパイラの設計者がこの方式を選択した理由は、/G6 では、レジスタ アクセスの部分的なストールを回避するためのコードが増えるため、Pentium のパフォーマンスが低下するからです。/G5 の Pentium 命令ペアリング最適化は、Pentium Pro または Pentium II には影響を与えません。ほとんどのターゲットが Pentium Pro マシンや Pentium II マシンであり、Pentium マシンのパフォーマンスは考えなくてもよい場合は、/G6 を使うことによって、多少速度を改善できることがあります。
Visual C++ は、Visual C++ の動作を制御するためのほかのさまざまな最適化スイッチを備えています。3 つの最もよく使われる設定は、デバッグのためにすべての最適化を無効にする /Od、コードのサイズを最小化する /O1、コードの速度を最大化する /O2 です。これら 3 つの設定は、Visual C++ の [プロジェクトの設定] ダイアログ ボックスの [C/C++] タブにある [最適化] カテゴリのドロップダウン リストに表示される [無効(デバッグ時)]、[プログラム サイズ]、[実行速度] の中から選択できます。カスタム設定によって微調整を行うことができます。
サイズを最小化する /O1 スイッチは、/Ogsyb1 /Gfy と等価です。/Og は グローバルの最適化を有効にし、/Os は速度よりもコード サイズの小ささを優先させます。ほとんどのコードにはこの設定が適しています。/Oy はフレーム ポインタの省略を有効にし、/Ob1 は明示的にインラインと宣言されている関数のインライン展開を有効にします。
/Gf スイッチは、文字列プールを有効にします。文字列プールを有効にすると、コンパイラは複数の同じ文字列がある場合に、そのコピーを 1 つだけ .exe ファイルに取り込むことができます。/Gy スイッチを指定すると、コンパイラは、すべての関数を個別にパッケージ化関数 (COMDAT) にすることができます。これは、リンカが、DLL ファイルまたは .exe ファイルから個々の関数を除外したり、ファイル内の関数の順序を指定できることを意味しています。これにより、プログラムの実行時のサイズが小さくなります。
速度を最大化する /O2 は、/Ogityb1 /Gfy と等価です。/O1 との 2 つの相違点は、コンパイラが組み込み関数をインライン展開できるようにする /Oi が追加された点と、/Os が、小さいコードより速いコードを優先させる /Ot に変わった点です。
Visual C++ では、マスターの最適化設定が個々のビルド ターゲットに適用されます。最適化は通常、Microsoft® Win32® Debug ターゲットでは [無効 (デバッグ時)] に、Win32 Release ターゲットでは [実行速度] に設定されています。ただし[プログラム サイズ]に設定した場合でも、モジュール単位でスイッチ設定をオーバーライドすることができるので、TightLoop.cpp モジュールをより高速にする必要がある場合は、プロジェクトのほかの部分に影響を与えずに、このモジュールの最適化を [実行速度] に設定することができます。
プロジェクトの特定の最適化設定をオーバーライドすることもできます。たとえば、速度を優先させ、あらゆる関数のインライン展開を可能にする /O2 /Ob2 が、内部ループから呼び出される関数が含まれた CPU 集約型のモジュールに最適な設定である場合があります。この設定を行うには、統合開発環境の [プロジェクト設定] ダイアログ ボックスで、関数のインライン展開の制御の対象として [適合可能なものすべて] を選択し、その設定を CPU 集約型モジュールに適用します。
/GF スイッチは /Gf スイッチと同様に機能しますが、共通の文字列を読み取り専用メモリに格納します。大きいアプリケーションでは、この方式の方が、共通の文字列を読み取り/書き込みメモリに格納する方式より安全で、効率が高くなります。/GF を指定してコンパイルしたアプリケーションが共有文字列を変更しようとすると、エラーが発生します。/Gf を指定してコンパイルしたアプリケーションが共有文字列を変更しようとすると、変更は問題なく行われ、操作を意図していたかどうかに関係なく、その文字列が使われているすべての場所でその文字列が変更されます。
/Gr スイッチは、デフォルトの呼び出し規約を __fastcall に設定します。この設定は、1 〜 2 % 程度、一部のプログラムの速度を速くし、サイズを縮小する効果があります。__fastcall でコンパイルされた関数は、一部の引数を ECX レジスタおよび EDX レジスタで受け取り、その他の引数を右から左の順にスタックにプッシュすることができます。インライン アセンブリ言語が含まれた関数がある場合は、__fastcall 規約を使用するときに注意する必要があります。
optimize プラグマ
Visual C++ の最適化プラグマを使用して、最適化を関数レベルで制御することができます。たとえば、BagOfCode.cpp モジュール内の 1 つの CPU 集約型関数の実行速度を速くすると同時に、その他の関数のサイズを最小化するには、プロジェクトまたはモジュールあるいはその両方に対して [プログラム サイズ] を選択し、関連する関数を #pragma optimize ステートメントでくくります。
function pokey() {
//non-critical code here
}
#pragma optimize("t",on)
function NeedForSpeed() {
//time-critical code here
}
#pragma optimize("t",off)
function pokey2() {
//more non-critical code here
}
この例では、1 つの関数の最適化オプション "t" をオンにしましたが、これはサイズより速度を優先させる /Ot を設定する操作と等価です。
最適化設定のチューニング
Visual C++ の最適化の効果はどれほどあるのでしょうか。ここで CPU 集約型 の Dhrystone 整数ベンチマークを 400 MHz Pentium II で測定した数値を見てみましょう。
設定
|
Dpack1 のサイズ
|
Dpack2 のサイズ
|
Dhrystone
|
/Od (デバッグ)
|
19 KB
|
6 KB
|
225 MIPS
|
/O1 (プログラム サイズ)
|
13 KB
|
2 KB
|
600 MIPS
|
/O2 (実行速度)
|
14 KB
|
2 KB
|
715 MIPS
|
/O2 /Ob2 (実行速度とインライン展開)
|
14 KB
|
2 KB
|
850 MIPS
|
これは、速度の点でもサイズの点でも、世界最高クラスの最適化だといえるでしょう。400 MHz Pentium II プロセッサで毎秒 8 億回の Dhrystone 演算という値は、ベンチマークの平均演算回数が 1 サイクルにつき 2 回であることを意味しています。これは、ワーキング コードが命令キャッシュに収まっており、ほとんどのデータ メモリ アクセスがプリフェッチまたはキャッシュされており、さらにそれ以上の最適化によって C のソース コードから暗黙に定義されるカウントの一部の演算が除去されていることを示しています。
Dpack1 オブジェクト モジュールのサイズの 8 % というささやかなコストと、2 つのモジュールを合わせて 6 % のコストと引き替えに、/O2 /Ob2 が /O1 に比べて 35 % も速度が向上している点に注目してください。ところで、私は /G6 スイッチでターゲット CPU を Pentium Pro に切り替えようとしましたが、このベンチマークでは、識別できるような差は出ていません。
もちろん、環境によって最適化の効果は異なるでしょう。Dhrystone のようにタイトなループ内で整数の簡単な演算だけを行うコードは滅多にありません。各コードによってコンパイラの最適化設定に対する反応が異なるので、違う結果が出ることがあります。しかし、この種の分析は、読者がコンパイラのスイッチを賢く選択するうえの参考になります。
遅延ロード DLL
CPU サイクルの観点から見てページ違反のコストは高いことを既に説明しました。また、不要なコードを大量に読み込むと、プログラムの起動時の読み込みが遅くなると同時に、プログラムの操作中にページ違反が発生しやすくなることも指摘しました。この種の問題は、綿密に設計を行い、コンパイラの最適化を賢く利用することによってコードを小さく抑えることに加えて、コードを必要なときだけ読み込むことによって回避することができます。
Windows では、一貫してダイナミック リンク ライブラリ (DLL) と関数の動的な読み込みをサポートしています。残念ながら、LoadLibrary と GetProcAddress を呼び出して関数を読み込み、LoadLibrary 呼び出しと FreeLibrary 呼び出しを均衡させる方法は、開発者にとってやや面倒で、エラーが発生しやすい作業でもあります。
Visual C++ リンカは、現在、ほとんど透過的に行われる DLL の遅延ロードをサポートしています。関数が静的にリンクされているかのようにコードから関数を呼び出す一方で、.exe と同時にロードしたくない個々の DLL に対しては /delayload:<dllname> リンカ スイッチを指定することができます。遅延ロード ヘルパ ルーチンが含まれている Delayimp.lib にリンクすることも必要です。リンカは、遅延ロード DLL 内のインポート関数に対して ロード サンクを生成します。
サンクとは、関数ポインタが含まれた短い書き込み可能なコード ブロックです。Windows はサンクを使ってコード再配置を実装し、16 ビット関数と 32 ビット関数の間を橋渡しします。ここでいっているサンクは、透過的な遅延ロードを可能にするサンクです。このサンクは、遅延ロード ヘルパ ルーチンを呼び出すように初期設定されています。
したがって、遅延ロードとして指定された DLL をコードが初めて使うときは、遅延ロード ヘルパ関数が呼び出されます。この関数は、格納されている DLL のハンドルをチェックし、それがロードされていないことを確認し、それを LoadLibrary を使ってメモリに読み込みます。DLL 内の各関数を最初に使用するときは、ヘルパ関数が GetProcAddress を使ってその関数のエントリ ポイントを取り出し、サンクに保存します。その関数がそれ以降使用されるときは、サンクに格納されたポインタから関数が直接呼び出されます。
以下を呼び出すことによって、使い終わった遅延ロード DLL を明示的にアンロードすることができます。
__FUnloadDelayLoadedDLL
ただし、これを実行する必要はありません。
ランタイム ライブラリとシステム API オプション
C/C++ のランタイム ライブラリが、ときにホットスポットの原因となり得る部分は 3 つあります。それは、メモリ割り当て、メモリ コピー、ファイル I/O です。
メモリ割り当て
malloc と free および C++ の new メソッドと delete メソッドを支える頭脳である C のランタイム ライブラリ メモリ割り当てパッケージは、小さいメモリ ブロックは Windows のグローバル ヒープから取得して割り当て、大きいメモリ ブロックはグローバル ヒープ内で直接割り当てます。過去のランタイム ライブラリのヒープ割り当てはパフォーマンスが低いことで不評を買っていましたが、Visual C++ 6.0 のヒープ割り当ては、大幅に改善され、現在は優れたパフォーマンスを実現しています。
ただし、malloc に関しては改善の余地がいくらかあります。あまり多くのデータが格納されない大きいアドレス領域 (たとえばハッシュ テーブルなど) を使う必要がある場合は、VirtualAlloc を使ってアドレス領域全域を確保し、必要なブロックを選択的にコミットすることによって、パフォーマンスを改善できます。これにより、メモリ セットアップ時間を短縮すると共に、ワーキング セットのサイズを軽減し、ページ違反の回避を図ることができます。
アプリケーションがサイズの異なる数個のメモリ ブロックを割り当てるために非常に頻繁にヒープを使用する場合は、HeapCreate で 1 つ以上のプライベート ヒープを作成し、HeapAlloc でそこからブロックを割り当てることによって、malloc のパフォーマンスを改善できることがあります。最良の結果を得るには、同じサイズのすべての割り当てを 1 つのプライベート ヒープに収めます。含まれているすべてのブロックが同じサイズであるヒープは非常に管理しやすいため、この方法は効果があります。
メモリ コピー
メモリ コピーで問題になるのは、通常考えられる現象と反対の現象です。ランタイム ライブラリ関数の組み込みを有効にする (関数を呼び出す代わりに、短い命令ストリームをインラインで挿入する) と、プログラムの実行速度が遅くなることがあるのです。たとえば、memcpy のランタイム ライブラリ バージョンのソース コードを見ると、ルーチンが、アラインされていないバッファをあらかじめアラインし、Intel の CPU 上で 1 サイクル 4 バイトをコピーする最大幅のメモリ命令を使用するように取り計らっていることがわかります。コンパイラの組み込みは、一般的にアライン済みバッファではうまく動作し、組み込みを使用することによって、コードがグローバル オプティマイザに公開され、そこで改善されることもあります。
どちらを使用するかは、要求によって異なります。コードのサイズを考える必要がなく、ほとんどの場合アライン済みバッファまたは小さいバッファを扱う場合は、組み込みが適しています。バッファが大きくて、アラインされていない可能性があるときは、ランタイム ライブラリ関数を使った方がよいでしょう。
/O1 を指定してコンパイルすると、Visual C++ は、インライン組み込み関数の展開を無効にし、/O2 を指定してコンパイルすると、展開を有効にします。モジュール内のデフォルトの動作をオーバーライドするには、
#pragma intrinsic( function1 [, function2, ...] )
によって特定の組み込み関数の展開を有効にするか、
#pragma function( function1 [, function2, ...] )
によって特定のランタイム ライブラリ関数を強制的に呼び出します。
ファイル I/O
CPU にバインドされた関数にではなく、I/O にバインドされた関数にホットスポットが存在するプログラムはたくさんあります。たとえば、大量の画像ファイルを扱うデスクトップ パブリッシング プログラムでは、ドキュメントの読み込みが多大な時間を消費する可能性があります。
C および C++ のランタイム ライブラリ関数は便利で移植性も高いのですが、必ずしも最も効率的な選択であるとはいえません。Win32 API で利用できる 2 つのオプション、メモリ マップ ファイルと非同期ファイル I/O を使って、速度を大幅に改善できることがあります。
ファイルのマップ ビューとも呼ばれるメモリ マップ ファイルは、ファイルをアプリケーションのアドレス領域に格納することによって、ファイルが RAM のように見えるようにします。ファイル マッピングを作成し、ファイルのマップされたビューを開く操作は、ほとんど瞬時に実行でき、それ以降のアプリケーションからファイルへのアクセスは、システムがすべてキャッシュすることができます。アプリケーションが最初にファイルを開くときに、大きいファイルのすべての部分を連続的に読み込むために動作がもたつく場合は、マップ ビューに切り替えることによって、速度が劇的に速くなることがあります。マップ ファイルは Windows のすべての 32 ビット バージョンでサポートされています。次のサンプルは、マップ ファイルの使い方を示しています。
//
// Open a handle to a file
//
FileHandle = CreateFile(
ImageName,
GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ,
NULL,
OPEN_EXISTING,
0,
NULL
);
//
// Create a file mapping object for the entire file
//
MappingHandle = CreateFileMapping(
FileHandle,
NULL,
PAGE_READWRITE,
0,
0,
NULL
);
//
// Release our handle to the file
//
CloseHandle( FileHandle );
//
// Map the file into our address space
//
BaseAddress = MapViewOfFile(
MappingHandle,
FILE_MAP_READ | FILE_MAP_WRITE,
0,
0,
0
);
//
// Data in the file is now accessable using pointers based at BaseAddress
//
//
// Release our handle to the mapping object
//
CloseHandle( MappingHandle );
//
// Unmap the file when you are done
//
UnmapViewOfFile( BaseAddress );
非同期ファイル I/O を利用すると、アプリケーションは、ファイル転送処理をインターリーブすることができます。Win32 API で利用できる非同期ファイル I/O には、いくつかの形態があります。オーバーラップ I/O 操作を開始した後は、操作の完了を知らせるイベントを待機するか、完了コールバック ルーチンを使用するか、I/O 完了ポートを使用することができます。残念ながら、Windows 9x 上でのこれらの機能のサポートはごく狭い範囲に限定されています。たとえば、ReadFileEx は、通信リソースとソケットでのみ機能します。
これらの 3 つのメカニズムで最も効率がよい I/O 完了ポートは Microsoft Windows NT® 3.5 以降でのみサポートされています。このメカニズムは、スケーラビリティがよく、マルチプロセッサの利点を生かすことができるため、ファイル処理が多いサーバー アプリケーションに適しています。非常に効率のよい別のメカニズムである scatter/gather I/O は Windows NT 4.0 以降でのみサポートされています。次の例は、I/O 完了ポートを使って、パラレルで実行される複数の書き込み操作を生成する方法を示しています。
hFile = CreateFile(
FileName,
GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE,
NULL,
fRaw ? OPEN_EXISTING : OPEN_ALWAYS,
FileFlags,
NULL
);
if ( hFile == INVALID_HANDLE_VALUE ) {
printf("Error opening file %s %d\n",FileName,GetLastError());
return 99;
}
hPort = CreateIoCompletionPort(
hFile,
NULL, // new completion port
(DWORD)hFile, // per file context is the file handle
0
);
ZeroMemory(&ov,sizeof(ov));
ov.Offset = 0;
PendingIoCount = 0;
//
// Issue the writes
//
for (WriteCount = 0; WriteCount < NumberOfWrites; WriteCount++){
reissuewrite:
b = WriteFile(hFile,Buffer,BufferSize,&n,&ov);
if ( !b && GetLastError() != ERROR_IO_PENDING ) {
//
// we reached our limit on outstanding I/Os
//
if ( GetLastError() == ERROR_INVALID_USER_BUFFER ||
GetLastError() == ERROR_NOT_ENOUGH_QUOTA ||
GetLastError() == ERROR_NOT_ENOUGH_MEMORY ) {
//
// wait for an outstanding I/O to complete and then go again
//
b = GetQueuedCompletionStatus(
hPort,
&n2, // number of bytes
&key, // per file context
&ov2, // per I/O context is the overlapped struct
(DWORD)-1
);
if ( !b ) {
printf("Error picking up completion write %d\n",GetLastError());
return 99;
}
PendingIoCount--;
goto reissuewrite;
}
else {
printf("Error in write %d (pending count = %d)\n",
GetLastError(),PendingIoCount);
return 99;
}
}
PendingIoCount++;
ov.Offset += BufferSize;
}
//
// Pick up the I/O completion
//
for (WriteCount = 0; WriteCount < PendingIoCount; WriteCount++){
b = GetQueuedCompletionStatus(
hPort,
&n2,
&key,
&ov2,
(DWORD)-1
);
if ( !b ) {
printf("Error picking up completion write %d\n",GetLastError());
return 99;
}
}
特に分散アプリケーションでは、コードのボトルネックを見つける作業が、腕利きの刑事に捜査を依頼したくなるくらい困難をきわめることがあります。ニューヨークでブラウザを実行しているユーザーから Web クライアント/サーバー アプリケーションの動作が遅いという苦情があった場合、その原因となっているのは、ユーザーのマシン上の Microsoft ActiveX® コントロールかもしれないし、過剰な負荷を抱えているバージニアの Web サーバーかもしれないし、あるいは Web サーバー上で実行されている効率の悪い中間層の COM オブジェクト、過剰な負荷を抱えたクリーブランドのデータベース、データベース上の欠落したインデックスかもしれません。
Microsoft Visual Studio® Analyzer を使って、アプリケーションのすべての層とシステムにまたがってコンポーネント間のやり取りのタイミング データを収集することにより、分散アプリケーションのボトルネックを見つけることができます。Visual Studio Analyzer は、ボトルネックの原因となっているコンポーネントを特定する目的には適していますが、コンポーネント内のホットスポット コードを見つける目的には適していません。
C++ コンポーネントがボトルネックの原因となっている場合は、Visual C++ プロファイラや Numega の TrueTime、Rational の Visual Quantify などのサードパーティ製プロファイリング ツールを使ってホットスポットを詳しく調べることができます。
通常は、関数レベルで Visual C++ のプロファイリングを開始します。[プロジェクトの設定] ダイアログ ボックスの [リンク] タブの [一般] カテゴリのチェック ボックスを使って関数レベルのプロファイリングを有効にすることができます。プロファイリングを有効にするにはAlt + E を押下してください。プロジェクトをリビルドすると、プロファイリングができる状態になります。
実際のプロファイリングを行うには、次の手順に従います。
- [ビルド] メニューの [プロファイル] コマンドを選択します。
- [関数のタイミング] ボタンをクリックします。
- [OK] をクリックします。
通常より動作速度は遅くなりますが、プログラムが実行され、関数のタイミング情報がランタイム環境の [プロファイル] 出力タブに表示されます。出力を詳しく調べることによって、ホットスポットが含まれている関数を突き止めることができるので、もう一度プロファイラを実行し、その関数のソース コードを行レベルまで掘り下げることができます。
ホットスポットとなっている内部ループがわかったら、コードのタイミングを測定すると役に立ちます。正確なタイミングの測定は困難な作業になることがあります。
問題の 1 つは、C のランタイム ライブラリ ルーチン clock() が使用する Windows 標準の時刻表示用の時計があまり正確でないことです。これに対処するには、高分解能タイマーを使用します。
2 番目の問題は、Windows で実行されているシステム プロセスなどのほかのプロセスが、測定対象のプロセスと競合する可能性がある点です。これに対処するには、Win32 関数 SetThreadPriority を使って、通常より高い優先度でテスト プロセスを実行します。
Win32 プログラムで高分解能タイマーにアクセスする方法は 2 つあります。1 つはシステム関数 QueryPerformanceCounter を使って測定対象のコードの前と後で高分解能タイマーを読み取る方法です。最近の Intel の CPU で使えるほかの方法は、RDTSC 命令を使って CPU から直接、高分解能タイマーを読み取る方法です。いずれの場合も、クロック ティックを秒に換算するために、事前に高分解能クロックの周波数を決めておく必要があります。次のコード サンプルは、QueryPerformanceCounter メソッドを使用し、システムにパフォーマンス カウンタがない場合に限って clock() を予備的に使用します。自分のプログラムでこのコードを使用する場合は、DoBench(myfunc) を呼び出してください。戻り値は、秒単位の myfunc ランタイムになります。
#include "time.h"
enum { ttuUnknown, ttuHiRes, ttuClock } TimerToUse = ttuUnknown;
LARGE_INTEGER PerfFreq; // ticks per second
int PerfFreqAdjust; // in case Freq is too big
int OverheadTicks; // overhead in calling timer
void DunselFunction() { return; }
void DetermineTimer()
{
void (*pFunc)() = DunselFunction;
// Assume the worst
TimerToUse = ttuClock;
if ( QueryPerformanceFrequency(&PerfFreq) )
{
// We can use hires timer, determine overhead
TimerToUse = ttuHiRes;
OverheadTicks = 200;
for ( int i=0; i < 20; i++ )
{
LARGE_INTEGER b,e;
int Ticks;
QueryPerformanceCounter(&b);
(*pFunc)();
QueryPerformanceCounter(&e);
Ticks = e.LowPart - b.LowPart;
if ( Ticks >= 0 && Ticks < OverheadTicks )
OverheadTicks = Ticks;
}
// See if Freq fits in 32 bits; if not lose some precision
PerfFreqAdjust = 0;
int High32 = PerfFreq.HighPart;
while ( High32 )
{
High32 >>= 1;
PerfFreqAdjust++;
}
}
return;
}
double DoBench(void(*funcp)())
{
double time; /* Elapsed time */
// Let any other stuff happen before we start
MSG msg;
PeekMessage(&msg,NULL,NULL,NULL,PM_NOREMOVE);
Sleep(0);
if ( TimerToUse == ttuUnknown )
DetermineTimer();
if ( TimerToUse == ttuHiRes )
{
LARGE_INTEGER tStart, tStop;
LARGE_INTEGER Freq = PerfFreq;
int Oht = OverheadTicks;
int ReduceMag = 0;
SetThreadPriority(GetCurrentThread(),
THREAD_PRIORITY_TIME_CRITICAL);
QueryPerformanceCounter(&tStart);
(*funcp)(); //call the actual function being timed
QueryPerformanceCounter(&tStop);
SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_NORMAL);
// Results are 64 bits but we only do 32
unsigned int High32 = tStop.HighPart - tStart.HighPart;
while ( High32 )
{
High32 >>= 1;
ReduceMag++;
}
if ( PerfFreqAdjust || ReduceMag )
{
if ( PerfFreqAdjust > ReduceMag )
ReduceMag = PerfFreqAdjust;
tStart.QuadPart = Int64ShrlMod32(tStart.QuadPart, ReduceMag);
tStop.QuadPart = Int64ShrlMod32(tStop.QuadPart, ReduceMag);
Freq.QuadPart = Int64ShrlMod32(Freq.QuadPart, ReduceMag);
Oht >>= ReduceMag;
}
// Reduced numbers to 32 bits, now can do the math
if ( Freq.LowPart == 0 )
time = 0.0;
else
time = ((double)(tStop.LowPart - tStart.LowPart
- Oht))/Freq.LowPart;
}
else
{
long stime, etime;
SetThreadPriority(GetCurrentThread(),
THREAD_PRIORITY_TIME_CRITICAL);
stime = clock();
(*funcp)();
etime = clock();
SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_NORMAL);
time = ((double)(etime - stime)) / CLOCKS_PER_SEC;
}
return (time);
}
この記事では、Visual C++ コンパイラでソース コードの速度とサイズを最適化する方法を論じ、コードのサイズが重要である理由を検討しました。また、コード生成および Visual C++ でサポートされている最適化スイッチとプラグマ、プロジェクトに最適なスイッチの選び方と関数レベルで最適化スイッチをオーバーライドする理由と方法を説明しました。
DLL を遅延ローディングの対象として指定する方法や、メモリ割り当て、メモリ コピー、ファイル I/O の分野でのランタイム ライブラリの問題についても論じました。最後に、コードのプロファイルを行ってホットスポットを見つける方法とコードのタイミングを正確に測定する方法について論じました。
プログラムをうまく最適化する作業は、残念ながら、Visual C++ のような優れた最適化コンパイラを使っている場合でさえ、単にコンパイラのスイッチを設定するだけでは済まない骨の折れる作業です。しかし、適切なアルゴリズム、適切なデータ構造、適切なコードを使用し、パフォーマンスのテストとチューニングを粘り強く継続すれば、誰もが最適化を達成できるはずです。
Martin Heller は、マサチューセッツ州、アンドーバーでコンサルタント業務、ソフトウェアの開発、執筆活動に従事しています。連絡先は meh@mheller.com です。