.NET アプリケーションのパフォーマンス関連のヒントとトリック
.NET 開発者プラットフォーム ホワイト ペーパー
Emmanuel Schanzer
Microsoft Corporation
2001 年 8 月
要約: この記事は、アプリケーションの微調整を行って、マネージドの世界で最適なパフォーマンスを引き出したいと考えている開発者を対象としています。データベース、Windows フォーム、および ASP アプリケーションのサンプル コード、解説、設計ガイドラインと、Microsoft Visual Basic および Managed C++ のための言語固有のヒントを示しています。
目次
概要
あらゆるアプリケーションのためのパフォーマンス関連のヒント
データベース アクセスのためのヒント
ASP.NET アプリケーションのためのパフォーマンス関連のヒント
Visual Basic での移植と開発のためのヒント
Managed C++ での移植と開発のためのヒント
関連リソース
付録: 仮想呼び出しと割り当てのコスト
概要
このホワイト ペーパーは、.NET 用のアプリケーションを作成しており、パフォーマンスを改善するための各種の手法を探している開発者のためのリファレンスとして書かれています。.NET に初めて関わる開発者は、プラットフォームそのものと、使用する言語の両方についての知識を身に着ける必要があります。このペーパーではこれらの知識を前提とし、プログラマはプログラムを実行するために必要な情報をすでに十分に知っていると仮定しています。既存のアプリケーションを .NET に移植しようとしている人は、移植に着手する前にこのドキュメントを読むことをお勧めします。いくつかのヒントは設計フェーズにも役立ち、移植を始める前に注意しておかなくてはならない情報を提供しています。
このペーパーは、プロジェクトと開発者のタイプに応じたヒントを提供する複数のセグメントから構成されています。最初のセットは、あらゆる言語で開発を行っている人々のための必読のヒントで、共通言語ランタイム (CLR) 上のあらゆるターゲット言語に役立つ助言が含まれています。その後には、ASP 固有のヒントを含んだ関連セクションがあります。第 2 のセットのヒントは言語別に分かれており、Managed C++ と Microsoft® Visual Basic® を使うときの具体的なヒントがあります。
スケジュールの制限のために、バージョン 1 (v1) のランタイムは、まず広い範囲の機能をターゲットとした後に、特殊なケースの最適化を行うという順序で開発が進められています。このため、いくつかのケースでパフォーマンスの問題が生じています。このペーパーでは、このようなケースに対処するためのいくつかのヒントを示しています。これらのケースはすでに体系的に識別され、最適化されているため、次のバージョン (vNext) ではこのタイプのヒントは役立たなくなります。このペーパーでは随時この点を指摘しており、実際にそのヒントを採用するかは読者の判断に委ねています。
あらゆるアプリケーションのためのパフォーマンス関連のヒント
どの言語であっても、CLR を使用するときには覚えておくべきヒントがいくつかあります。これらはあらゆる開発者に適用されるものであり、パフォーマンス上の問題を扱うときには最初の防御ラインとなります。
送出する例外の数を減らす
例外の送出にはきわめて高いコストがかかることがあるので、大量の例外を送出しないよう注意してください。アプリケーションが送出している例外の数を調べるには、Perfmon を使用します。アプリケーションの一部分が予想していた以上の例外を送出していることを知って驚くかもしれません。また、これよりも細かい単位で調べるには、パフォーマンス カウンタを使って例外番号をプログラム的にチェックすることもできます。
例外を大量に送出しているコードを見つけ出し、設計しなおすことで、パフォーマンスをかなり向上させることができます。これは try/catch ブロックの数とは関係がないことに注意してください。コストは実際に例外が送出された時点で発生します。つまり、try/catch ブロックは好きな数だけ使用することができます。パフォーマンスが低下するのは、実際に例外を大量に使用しているときだけです。たとえば、フローの制御に例外を使用するというようなことは避けなくてはなりません。
次に、例外がどれほど高コストになりうるかの例を示します。このプログラムは、単に For ループを実行し、数千個の例外を生成してから終了します。throw 文をコメントにして、速度がどれほど変わるかを確認してください。例外が驚くべきオーバーヘッドを発生させていることがわかるでしょう。
public static void Main(string[] args){
int j = 0;
for(int i = 0; i < 10000; i++){
try{
j = i;
throw new System.Exception();
} catch {}
}
System.Console.Write(j);
return;
}
- ランタイム自身も例外を送出することがある点に注意してください。たとえば、Response.Redirect() は ThreadAbort 例外を送出します。例外を明示的に送出していなくても、例外を送出する関数を使用している可能性はあります。Perfmon を使って実態を確認し、デバッガでその原因をチェックしてください。
- Visual Basic 開発者のためのコメント: Visual Basic は、オーバーフローやゼロによる除算などが例外を送出するように、int のチェックをデフォルトでオンにします。これをオフにすると、パフォーマンスが向上します。
- COM を使用している場合には、HRESULTS が例外として返される場合があることに注意してください。これは慎重に追跡する必要があります。
大きなサイズの (chunky な) 呼び出しを行う
chunky な呼び出しとは、オブジェクトの複数のフィールドを初期化するメソッドのように、複数のタスクを実行する関数呼び出しです。これとは対照的なのが、非常に単純なタスクを実行し、何らかの仕事を行うために複数の呼び出しを必要とするチャッティー (chatty) な呼び出しです (たとえば、オブジェクトの個々のフィールドをそれぞれ別の呼び出しで設定するなど)。単純なアプリケーション ドメイン内のメソッド呼び出しよりもオーバーヘッドが大きい呼び出しでは、複数のメソッドへのチャッティーな呼び出しを行うのではなく、大きなサイズの呼び出しを行うことが重要です。P/Invoke、interop、およびリモーティングの呼び出しはいずれもオーバーヘッドを持っているので、頻繁には使用しないようにしてください。いずれの場合も、オーバーヘッドの大きい小さな呼び出しに依存しないように、アプリケーションを設計する必要があります。
マネージド コードとアンマネージド コードの間の呼び出しでは、移行が行われます。プログラマはランタイムのおかげできわめて簡単に相互運用を行えるようになっていますが、これはパフォーマンスとの引き換えによって実現されています。移行が起こったときには、以下のステップを実行する必要があります。
- データ マーシャリングを実行する
- 呼び出し規約を修正する
- 呼び出し先が保存しているレジスタを保護する
- GC がアンマネージド スレッドをブロックしないように、スレッド モードを切り替える
- マネージド コードへの呼び出しで、例外処理フレームを作成する
- スレッドの制御を奪う (オプション)
移行に要する時間を短縮するには、可能なときには P/Invoke を利用するようにします。オーバーヘッドは、データ マーシャリングが必要な場合には 31 個の命令にマーシャリングのコストを加えたもの、不要な場合には 8 個の命令に過ぎません。COM interop ははるかにコストが高く、最高で 65 個の命令を必要とします。
データ マーシャリングは必ずしも高コストではありません。プリミティブ型はマーシャリングをほとんど必要とせず、明示的なレイアウトを持つクラスも低コストです。真のコストは、ASCII から Unicode へのテキスト変換のようなデータ変換の際に発生します。マネージド境界を越えて渡されるデータの変換が、必要なときにのみ行われていることを確認してください。プログラム間で特定のデータ型またはフォーマットのみを受け渡すように合意することで、マーシャリングのオーバーヘッドを大幅に減らせることがあります。
一部の型は blittable な型と呼ばれ、マネージド/アンマネージド境界でマーシャリングなしに直接コピーされます。これらは sbyte、byte、short、ushort、int、uint、long、ulong、float、および double です。これらの型と、blittable な型を含んでいる ValueType および 1 次元配列はオーバーヘッドなしに受け渡すことができます。マーシャリングの詳細については、MSDN Library を参照してください。マーシャリングに大量の時間を費やしている場合には、慎重に目を通すことをお勧めします。
ValueType を使った設計
可能ならば単純な構造体を使用し、それが不可能な場合にはボックス化とアンボックス化を頻繁に行わないようにします。次に、速度の違いを示す簡単な例を示します。
using System;
namespace ConsoleApplication{
public struct foo{
public foo(double arg){ this.y = arg; }
public double y;
}
public class bar{
public bar(double arg){ this.y = arg; }
public double y;
}
class Class1{
static void Main(string[] args){
System.Console.WriteLine("starting struct loop...");
for(int i = 0; i < 50000000; i++)
{foo test = new foo(3.14);}
System.Console.WriteLine("struct loop complete.
starting object loop...");
for(int i = 0; i < 50000000; i++)
{bar test2 = new bar(3.14); }
System.Console.WriteLine("All done");
}
}
}
この例を実行すると、構造体のループの方が何倍も速いことがわかります。ただし、ValueType をオブジェクトのように扱うときには注意する必要があります。この際にはプログラムのボックス化とアンボックス化のオーバーヘッドが生じ、単にオブジェクトを使ったときよりもコストが大きくなる可能性もあります ! 実際に確認してみたい場合は、foo と bar の配列を使用するように上のコードを変更してください。パフォーマンスが同等かそれ以下になることがわかるはずです。
トレードオフ ValueType はオブジェクトよりもはるかに柔軟性が低く、不適切に使用するとパフォーマンスをかえって低下させます。いつどのように使用するかをきわめて慎重に決める必要があります。
上のサンプルを変更し、foo と bar を配列またはハッシュテーブルの中に格納してください。1 回のボックス化とアンボックス化の操作で、速度上の利点が失われることがわかります。
GC の割り当てとコレクションを調べることで、ボックス化とアンボックス化をどれほど頻繁に行っているかを追跡することができます。これは、外部で Perfmon を使用するか、コード内でパフォーマンス カウンタを使用することで行えます。
ValueType の詳しい解説については、「.NET Framework のランタイム テクノロジに関するパフォーマンス上の注意事項」を参照してください。
グループの追加に AddRange を使用する
コレクション全体を追加するときには、コレクション内の各項目を繰り返し処理によって追加するのではなく、AddRange を使用します。ほぼすべての Windows コントロールとコレクションが Add メソッドと AddRange メソッドを持っており、それぞれは異なる目的に合わせて最適化されています。Add は 1 つの項目を追加するときに有効で、AddRange はいくらかオーバーヘッドがあるにせよ、複数の項目を追加するときには全体として高いパフォーマンスを実現します。以下に、Add と AddRange をサポートしているクラスの例を示します。
- StringCollection、TraceCollection など
- HttpWebRequest
- UserControl
- ColumnHeader
ワーキング セットを小さくする
ワーキング セットを小さくするために、使用するアセンブリの数を最小限に抑えます。1 つのメソッドを使用するためだけにアセンブリ全体をロードしていると、ごくわずかな機能のために多大なコストを支払うことになります。そのメソッドの機能を、すでにロードしているコードを使って再現できないかどうか調べてみてください。
ワーキング セットの追跡は困難であり、1 つの記事全体を使って解説すべきようなトピックです。以下に、参考になりそうなヒントをいくつか示します。
- ワーキング セットの追跡には vadump.exe を使用します。これについては、マネージド環境用の各種のツールを扱っている別のホワイト ペーパーで説明しています。
- Perfmon かパフォーマンス カウンタを使用します。これらは、ロードされたクラスの数や、JIT で処理されたメソッドの数に関する詳しいフィードバックを提供してくれます。ローダーの中で費やしている時間や、実行時間の何パーセントがページングに費やされているかなどの情報を得ることができます。
文字列の繰り返し処理で For ループを使用する - バージョン 1
C# では、foreach キーワードを使ってリストや文字列などの中の項目を巡回し、各項目に対して操作を実行することができます。これはさまざまな型に使用できる汎用の列挙子のような役割を果たす、きわめて強力なツールです。この汎用性のトレードオフは速度にあり、文字列の繰り返し処理に大きく依存している場合には、代わりに For ループを使用するべきです。文字列は単純な文字配列なので、他の構造と比べると、はるかに小さいオーバーヘッドで巡回することができます。JIT は (多くの場合は) For ループの中の境界チェックやその他のチェックを最適化によって削除できるだけのインテリジェンスを備えていますが、foreach を使った巡回ではこのような最適化を行うことができません。最終的にバージョン 1 では、文字列を For ループで処理すると、foreach を使った場合の 5 倍の速度が実現されています。これは将来のバージョンでは変わる可能性がありますが、バージョン 1 においてはパフォーマンスの改善に大いに有効です。
次に、速度の違いを実証するための単純なテスト用メソッドを示します。これを実行した後に、For ループを削除して、foreach 文のコメントを外してみてください。筆者のマシン上では、For ループの実行時間は約 1 秒だったのに対し、foreach 文では 3 秒になりました。
public static void Main(string[] args) {
string s = "monkeys!";
int dummy = 0;
System.Text.StringBuilder sb = new System.Text.StringBuilder(s);
for(int i = 0; i < 1000000; i++)
sb.Append(s);
s = sb.ToString();
//foreach (char c in s) dummy++;
for (int i = 0; i < 1000000; i++)
dummy++;
return;
}
}
トレードオフ foreach ははるかに読みやすいという利点を備えていますし、将来的には文字列のような特殊なケースでも For ループと同じほど高速化されると思われます。文字列操作がパフォーマンスの真の問題になっているのでない限り、あえて読みにくいコードに変更するだけの価値はないかもしれません。
複雑な文字列操作には StringBuilder を使用する
文字列が変更されると、ランタイムは新しい文字列を作成してそれを返し、元の文字列はガーベジ コレクションの対象とします。ほとんどの場合、これは高速で単純な処理ですが、文字列が繰り返し変更されるようなケースではパフォーマンスに負担がかかります。これは割り当て操作が高コストになるためです。次の例は文字列への追加を 50,000 回行うプログラムですが、その後には StringBuilder オブジェクトを使って文字列をその場で変更する例を示しています。実際に実行してみればすぐにわかるように、StringBuilder のコードの方がはるかに高速です。
namespace ConsoleApplication1.Feedback{
using System;
public class Feedback{
public Feedback(){
text = "You have ordered: \n";
}
public string text;
public static int Main(string[] args) {
Feedback test = new Feedback();
String str = test.text;
for(int i=0;i<50000;i++){
str = str + "blue_toothbrush";
}
System.Console.Out.WriteLine("done");
return 0;
}
}
}
|
namespace ConsoleApplication1.Feedback{
using System;
public class Feedback{
public Feedback(){
text = "You have ordered: \n";
}
public string text;
public static int Main(string[] args) {
Feedback test = new Feedback();
System.Text.StringBuilder SB =
new System.Text.StringBuilder(test.text);
for(int i=0;i<50000;i++){
SB.Append("blue_toothbrush");
}
System.Console.Out.WriteLine("done");
return 0;
}
}
}
|
Perfmon で、数千個の文字列の割り当てを行わなかったときにどれだけの時間が節約されるかを確認します。このためには、.NET CLR Memory のリストの下の "% time in GC" カウンタの値を調べます。また、節約できた割り当ての回数と、コレクションの統計情報も見ることができます。
トレードオフ StringBuilder の作成には、時間とメモリの両方の点でいくらかのオーバーヘッドが付随します。高速なメモリを搭載しているマシンでは、5 回ほどの操作を行うのであれば、StringBuilder を使う意味があります。原則として、10 回以上の操作を行っている場合には、低速なマシンであってもオーバーヘッドを正当化できると考えられます。
Windows フォーム アプリケーションをプリコンパイルする
メソッドは初めて使用されるときに JIT で処理されます。つまり、アプリケーションがスタートアップ時に多数のメソッド呼び出しを行っている場合には、スタートアップ時のペナルティが大きくなるということです。Windows フォームは OS 内の共有ライブラリを頻繁に使用しており、それらを起動する際のオーバーヘッドは他の種類のアプリケーションよりもはるかに高くなる可能性があります。このため、つねにそうなるわけではありませんが、Windows フォーム アプリケーションをプリコンパイルすると、通常はパフォーマンスが向上します。他のシナリオでは、一般に JIT に任せてしまった方がよい結果が出ますが、Windows フォームを使って開発を行っているプログラマはプリコンパイルを行うことを検討してみてください。
Microsoft は ngen.exe によってアプリケーションのプリコンパイルを可能にしています。ngen.exe はインストールの時点で、またはアプリケーションを配布する前に実行することができます。もちろん、インストール時に ngen.exe を実行すれば、インストール先のマシンに合わせてアプリケーションが最適化されるので有利です。プログラムを出荷する前に ngen.exe を実行すると、開発用のマシンに適用される最適化しか行われません。プリコンパイルがどれほど有利かを示すために、筆者は自分のマシン上で非公式的なテストを行いました。以下に示すのは、約 100 個のコントロールを含んだ Windows フォーム アプリケーション ShowFormComplex のコールド スタートアップ時間です。
| コードの状態 |
時間 |
| Framework を JIT
ShowFormComplex を JIT
|
3.4 秒 |
| Framework をプリコンパイル、ShowFormComplex を JIT |
2.5 秒 |
| Framework をプリコンパイル、ShowFormComplex をプリコンパイル |
2.1 秒 |
どのテストも再ブートを行った後に実行しています。ここからわかるように、Windows フォーム アプリケーションは起動時に多数のメソッドを使用するため、プリコンパイルを行うとパフォーマンスが大幅に改善されます。
jagged 配列を使用する - バージョン 1
v1 の JIT は、jagged 配列 (「配列の配列」) を矩形の配列よりも効率的に最適化することができ、その違いは顕著なものです。次の表は、C# と Visual Basic の両方で、矩形の配列の代わりに jagged 配列を使ったときのパフォーマンスの向上を示しています (大きい値の方が優れたパフォーマンスを表します)。
| |
C# |
Visual Basic .NET |
| 代入 (jagged)
代入 (矩形)
|
14.16
8.37
|
12.24
8.62
|
| ニューラル ネット (jagged)
ニューラル ネット (矩形)
|
4.48
3.00
|
4.58
3.13
|
| 数値のソート (jagged)
数値のソート (矩形)
|
4.88
2.05
|
5.07
2.06
|
代入のベンチマークは、『Quantitative Decision Making for Business』 (Gordon, Pressman, and Cohn; Prentice-Hall。絶版) のステップ バイ ステップ ガイドのものを流用した単純な代入アルゴリズムです。ニューラル ネット テストは、小さなニューラル ネットワーク上で一連のパターンを実行します。数値のソートについては説明の必要はないでしょう。これらのベンチマークを総合すると、現実のアプリケーションのパフォーマンスの適切な指標が得られます。
結果からわかるように、jagged 配列を使用すると、パフォーマンスが劇的に向上することがあります。jagged 配列に対して行われている最適化は、将来のバージョンの JIT に追加される予定ですが、v1 では jagged 配列を使用することでかなりの時間の節約になります。
IO バッファのサイズを 4KB〜8KB に収める
ほとんどすべてのアプリケーションで、バッファのサイズを 4KB〜8KB の範囲に収めることで最高のパフォーマンスが得られます。ごく特殊な状況では、これよりも大きなバッファを使うとパフォーマンスが改善されることがありますが (予測可能なサイズの大きな画像を読み込む場合など)、99.99% のケースでは単なるメモリの無駄になります。BufferedStream から派生したすべてのバッファは、任意のサイズに設定することができますが、ほとんどのケースでは 4〜8 の間に設定することで最高のパフォーマンスが得られます。
非同期 IO の機会を捉える
稀にしか起こらないケースですが、非同期 IO が有効なことがあります。例としては、一連のファイルをダウンロードして展開する場合があります。1 つのストリームからビットを読み込み、CPU 上でデコードし、それを別のストリームに書き出すという処理です。非同期 IO を効果的に使うためには多大な努力が必要であり、不適切に使用するとかえってパフォーマンスが低下することがあります。しかし正しく適用すれば、非同期 IO はパフォーマンスを 10 倍ほども高めることができます。
非同期 IO を使ったプログラムのよい例が、MSDN Library にあります。
- 注意しなくてはならないのは、非同期呼び出しにはセキュリティ上の小さなオーバーヘッドがあるということです。非同期呼び出しを行うと、呼び出し元のスタックのセキュリティ状態がキャプチャされ、実際にその要求を実行するスレッドに転送されます。このオーバーヘッドは、コールバックが大量のコードを実行する場合や、非同期呼び出しが過剰に使用されていない場合は問題にならないでしょう。
データベース アクセスのためのヒント
データベース アクセスのチューニングを行う際の基本的な考え方は、必要な機能のみを使用し、「非接続状態」のアプローチをもとに設計を行うということです。1 つの接続を長期にわたって保持するのではなく、複数の接続を次々と作成していくという方法を採用します。このことを念頭に置いて設計を行うようにしてください。
Microsoft は、パフォーマンスを高めるために、クライアントからデータベースへの直接の接続ではなく、N 層戦略を採用することを推奨しています。既存の多くのテクノロジは多層形式のシナリオに合わせて最適化されているので、ぜひプログラムを開発する際の設計思想の 1 つとして取り入れてください。
最適なマネージド プロバイダを使用する
汎用のアクセサに頼るのではなく、適切なマネージド プロバイダを選択します。SQL 用の System.Data.SqlClient など、各種のデータベース専用に書かれているマネージド プロバイダが存在します。特殊化したコンポーネントが存在するのに、System.Data.Odbc のような汎用インターフェイスを使用していると、インダイレクションのレベルが増えてパフォーマンスが低下します。また、最適なプロバイダを使用すれば、自動的に別の言語を話せるようになるという利点もあります。たとえば、マネージド SQL クライアントは SQL データベースに対して TDS を使用し、汎用の OleDbprotocol と比べてパフォーマンスが劇的に改善されます。
可能ならばデータセットよりもデータ リーダーを使用する
データを保持しておく必要がないときは、データ リーダーを使用するようにします。これによりデータの読み込みを高速化し、ユーザーが望むならばそのデータをキャッシュに入れておくことができます。リーダーは、到着したデータを読み込み、ナビゲーションのためにデータセットに格納することなく削除するステートレスなストリームに過ぎません。ストリームのアプローチは、データをただちに使い始めることができるので、より高速であり、オーバーヘッドも小さくなります。ナビゲーションのためのキャッシングが有効かどうかは、同じデータをどれほど頻繁に必要とするかによって決まります。次の表に、サーバーからデータを取得するときの、ODBC プロバイダと SQL プロバイダの両方での DataReader と DataSet の違いを示します (大きい値の方がよい結果を表します)。
| |
ADO |
SQL |
| DataSet |
801 |
2507 |
| DataReader |
1083 |
4585 |
この表からわかるように、最高のパフォーマンスは、最適なマネージド プロバイダにデータ リーダーを組み合わせたときに実現されます。データをキャッシングする必要がないときは、データ リーダーを使用することでパフォーマンスを大幅に改善することができます。
MP マシンでは Mscorsvr.dll を使用する
スタンドアロンの中間層およびサーバー アプリケーションでは、マルチプロセッサ マシンで mscorsvr が使用されるようにします。mscorsvr はスケーリングやスループットの点で最適化されていませんが、そのサーバー バージョンは、複数のプロセッサが利用可能なときにスケーリングを可能にするいくつかの最適化が施されています。
可能な限りストアド プロシージャを使用する
ストアド プロシージャは、効果的に使用すると優れたパフォーマンスを実現できる、高度に最適化されたツールです。データ アダプタを通して挿入、更新、および削除を行うためのストアド プロシージャをセットアップしてください。ストアド プロシージャはインタープリタでの解釈、コンパイル、さらにはクライアントへの送信をも必要としないため、ネットワーク トラフィックとサーバーのオーバーヘッドの両方を節約します。CommandType.Text ではなく CommandType.StoredProcedure を使用するようにしてください。
動的な接続文字列には注意する
接続プーリングは、要求のたびに接続をオープンしてクローズすることのオーバーヘッドを回避し、複数の要求で接続を再利用するための便利な手法です。プログラマは暗黙のうちに、一意の接続文字列につき 1 つのプールを与えられます。接続文字列を動的に生成するときには、プーリングが行われるように、その文字列が毎回同一のものになるよう注意してください。また、委譲が行われるときには、ユーザー 1 人につき 1 つのプールが与えられることに注意します。接続プールに対してはさまざまなオプションを設定することができ、Perfmon を使うとプールの応答時間やトランザクション/秒などのパフォーマンスを追跡することができます。
使用しない機能をオフにする
不要ならば、自動トランザクション登録をオフにします。SQL マネージド プロバイダでは、次の接続文字列によって行います。
SqlConnection conn = new SqlConnection(
"Server=mysrv01;
Integrated Security=true;
Enlist=false");
データ アダプタでデータセットにデータを格納するときは、必要がない場合は主キー情報を取得しないようにします (例: MissingSchemaAction.AddWithKey を設定しない)。
public DataSet SelectSqlSrvRows(DataSet dataset,string connection,string query){
SqlConnection conn = new SqlConnection(connection);
SqlDataAdapter adapter = new SqlDataAdapter();
adapter.SelectCommand = new SqlCommand(query, conn);
adapter.MissingSchemaAction = MissingSchemaAction.AddWithKey;
adapter.Fill(dataset);
return dataset;
}
自動生成されるコマンドは使わない
データ アダプタを使用するときには、自動生成されるコマンドを使用するのを避けます。これらのコマンドはメタ データを取得するためにサーバーとの間で余分なやり取りをしなくてはならず、プログラマはサーバーとの対話を細かく制御できません。自動生成されるコマンドは便利ですが、パフォーマンスが重要なアプリケーションでは自分で処理するだけの価値があります。
ADO のレガシー デザインに注意する
アダプタに対してコマンドを実行したり、Fill の命令を行うと、クエリで指定されたすべてのレコードが返されることに注意してください。
サーバー カーソルがどうしても必要な場合は、t-sql のストアド プロシージャを通してインプリメントすることができます。しかし、サーバー カーソル ベースのインプリメンテーションはスケーラビリティが低いので、可能な限り避けてください。
必要ならば、ページ処理はステートレスかつコネクションレスな形でインプリメントします。データセットに新たなレコードを追加するには、次のようにします。
- PK 情報が存在することを確認する
- データ アダプタの select コマンドを適宜変更する
- Fill を呼び出す
データセットは小さく抑える
データセットには必要なレコードのみを格納します。データセットはすべてのデータをメモリ内に格納しており、より多くのデータを要求するほど、回線上での転送に時間がかかることに注意してください。
可能な限りシーケンシャル アクセスを使用する
データ リーダーでは CommandBehavior.SequentialAccess を使用します。データを小さな単位で回線から読み込むことができるので、blob データ型を扱う際には非常に重要です。一度に 1 つのデータの断片しか処理できませんが、大きなデータ型をロードするときのレイテンシはなくなります。オブジェクト全体を一度に扱う必要がない場合には、シーケンシャル アクセスを使用することで、はるかに高いパフォーマンスを実現できます。
ASP.NET アプリケーションのためのパフォーマンス関連のヒント
積極的にキャッシングを行う
ASP.NET を使用したアプリケーションを設計するときには、キャッシングに注意を払います。サーバー バージョンの OS では、サーバー側とクライアント側でのキャッシュの使い方を、さまざまなオプションで調整することができます。ASP にはパフォーマンスを高めるためのいくつかの機能とツールがあります。
出力キャッシング —ASP 要求の静的な結果を格納します。<@% OutputCache %> ディレクティブを使って指定します。
- Duration—アイテムがキャシュ内に存在する時間
- VaryByParam—キャッシュへの入力を Get/Post パラメータによって変える
- VaryByHeader—キャッシュへの入力を Http ヘッダーによって変える
- VaryByCustom—キャッシュへの入力をブラウザによって変える
- 希望の方法でキャッシュへの入力を変えるようにオーバーライドする
キャッシングをインテリジェントに行うことで、優れたパフォーマンスを引き出すことができます。どのような種類のキャッシングが必要かを検討することが重要です。たとえば、ログイン用に複数の静的ページを使用し、画像とテキストを含んだ動的に生成されるページを多数表示する複雑な e コマース サイトでは、ログイン ページには出力キャッシングを使用し、動的ページにはフラグメント キャッシングを使用するといいでしょう。たとえば、ツールバーはフラグメントとしてキャッシングすることができます。さらに高いパフォーマンスを引き出すには、サイト上でよく使われる画像やボイラープレート テキストを、キャッシュ API を使ってキャッシングします。キャッシングの詳細 (およびサンプル コード) については、ASP.NET Web サイトを参照してください。
セッション状態は必要な場合にのみ使用する
ASP.NET のきわめて強力な機能の 1 つは、e コマース サイトのショッピング カートやブラウザの履歴などのために、ユーザーのセッション状態を格納できる能力です。これはデフォルトでオンとなっているので、使用しない場合でもメモリ内でコストが発生します。セッション状態を使用しない場合は、asp ファイルに <@% EnabledSessionState = false %> を追加することで、セッション状態をオフにし、オーバーヘッドを減らすことができます。他にもいくつかのオプションが利用できますが、詳しくは ASP.NET Web サイトを参照してください。
セッション状態の読み込みのみを行うページでは、EnabledSessionState=readonly を選択することができます。これは完全な読み書き可能なセッション状態よりもオーバーヘッドが小さいため、機能の一部のみを使用しており、書き込みの能力のためのオーバーヘッドを避けたい場合に有用です。
ビュー状態は必要な場合にのみ使用する
ビュー状態の例としては、ユーザーが入力を行う長いフォームで、ブラウザの [戻る] をクリックし、再び同じページに来たときに、フォームに入力されていた情報がそのまま残っているという機能があります。この機能を使用していないときも、ビュー状態はメモリを占有し、パフォーマンスを低下させます。おそらく、この場合にパフォーマンスに影響を与える最も大きな原因は、ページがロードされるたびに、キャッシュを更新して確認するために、ラウンドトリップ シグナルをネットワーク上で送信しなくてはならないことでしょう。この機能はデフォルトでオンになっているので、<@% EnabledViewState = false %> を使ってビュー状態を使用しないことを指定する必要があります。ASP.NET Web サイトには、ビュー状態のその他のオプションと設定についての情報があります。
STA COM を避ける
アパートメント COM はアンマネージド環境でのスレッディングを扱うことを前提に設計されています。アパートメント COM には、シングルスレッドとマルチスレッドの 2 つの種類があります。MTA COM はマルチスレッディングを処理できるように設計されているのに対し、STA COM はメッセージング システムに依存してスレッド要求をシリアライズします。マネージドの世界はフリースレッドであり、シングルスレッド アパートメント COM を使用すると、すべてのアンマネージド スレッドが相互運用のために本質的に 1 つのスレッドを共有することになります。これはパフォーマンスを大幅に低下させるので、可能な限り避けなくてはなりません。アパートメント COM オブジェクトをマネージドの世界に移植できない場合は、それらを使用するページで <@%AspCompat = "true" %> を使用してください。STA COM の詳細については、MSDN Library を参照してください。
バッチ コンパイル
大きなページを Web に導入する前には、必ずバッチ コンパイルを行います。これは、個々のディレクトリごとに 1 つのページに対して 1 つの要求を発行し、CPU がアイドル状態に戻るまで待つことによって行えます。これにより、Web サーバーがページの送信を行うときに、コンパイルに手間取ることがなくなります。
不要な http モジュールを削除する
使用する機能に応じて、使用していない、または不要な http モジュールをパイプラインから削除します。余分なメモリと無駄になっているサイクルをなくすことで、速度をいくらか向上させることができます。
autoeventwireup 機能を避ける
autoeventwireup に依存する代わりに、Page のイベントをオーバーライドします。たとえば、Page_Load() メソッドを書く代わりに、public void OnLoad() メソッドのオーバーロードを試みます。これにより、ランタイムは個々のページに対して CreateDelegate() を行う必要がなくなります。
UTF が不要な場合は ASCII を使ってエンコードする
デフォルトでは、ASP.NET は要求と応答を UTF-8 としてエンコードするように構成されています。アプリケーションに必要なのが ASCII だけならば、UTF のオーバーヘッドをなくすことで、数サイクル分の節約になります。これはアプリケーションごとにしか設定できないことに注意してください。
最適な認証手法を使用する
ユーザーを認証する方法にはいくつかの種類があり、それぞれにコストが異なります (コストの小さい順では、なし、Windows、フォーム、Passport となります)。ニーズに合わせた、最も低コストの手法を使用するようにしてください。
Visual Basic での移植と開発のためのヒント
Microsoft® Visual Basic® 6 と Microsoft® Visual Basic® 7 の間では、目に見えないところで大量の変更が加えられており、パフォーマンス特性もそれとともに変わっています。CLR の増強された機能とセキュリティ上の制約のために、一部の関数は Visual Basic 6 と同じほど高速には動作できなくなっています。実際、Visual Basic .NET がその前身よりもパフォーマンスの点で劣っている分野もいくつかあります。幸いなことに、いいニュースが 2 つあります。
- 最悪の速度低下のほとんどは、コントロールを初めてロードするときなどのように、1 回限りの機能の中でのみ起こります。確かにコストはありますが、1 回支払うだけで済みます。
- Visual Basic .NET の方が高速な分野もたくさんあり、これらの分野は実行時に繰り返し使用される機能に集中しています。つまり、長期的に実行されればされるほど有利であり、いくつかのケースでは 1 回限りのコストを上回ります。
パフォーマンス上の問題のほとんどは、Visual Basic 6 の機能がランタイムによってサポートされておらず、Visual Basic .NET でも同じ機能を実現するために、コードを追加しなくてはならなかった分野で生じています。ランタイムの外部での処理はどうしても遅くなり、一部の機能はきわめて高コストになっています。ただし、これらの問題はごく簡単に避けることができます。パフォーマンスを最適化するために作業を行わなくてはならない分野は主に 2 つであり、その他にもあちこちで微調整を加えることができます。以下に示すヒントは、パフォーマンスの問題を回避し、Visual Basic .NET で大幅に高速化されている機能を有効に活用するのに役立つでしょう。
エラー処理
第 1 の問題はエラー処理です。これは Visual Basic .NET で大幅に変更されており、その変更に付随してパフォーマンス上の問題が生じています。基本的に、OnErrorGoto と Resume をインプリメントするために必要なロジックはきわめて高コストです。自分のコードを見直して、Err オブジェクトやその他のエラー処理メカニズムを使用しているすべての部分を確認してください。次に、個々のケースについて、それらを try/catch を使って書き換えられないかどうか検討します。多くの開発者は、ほとんどのケースで簡単に try/catch に変換することができ、プログラムのパフォーマンスを改善することができるはずです。基本原則は、「簡単に変換できるのであれば、変換せよ」というものです。
次に、On Error Goto を使用する単純な Visual Basic プログラムと、その try/catch バージョンを示します。
Sub SubWithError()
On Error Goto SWETrap
Dim x As Integer
Dim y As Integer
x = x / y
SWETrap:
Exit Sub
End Sub
Sub SubWithErrorResumeLabel()
On Error Goto SWERLTrap
Dim x As Integer
Dim y As Integer
x = x / y
SWERLTrap:
Resume SWERLExit
End Sub
SWERLExit:
Exit Sub
|
Sub SubWithError()
Dim x As Integer
Dim y As Integer
Try
x = x / y
Catch
Return
End Try
End Sub
Sub SubWithErrorResumeLabel()
Dim x As Integer
Dim y As Integer
Try
x = x / y
Catch
Goto SWERLExit
End Try
SWERLExit:
Return
End Sub
|
速度は顕著に改善されます。SubWithError() は、OnErrorGoto を使用した場合には 244 ミリ秒かかり、try/catch を使用した場合には 169 ミリ秒しかかかりません。第 2 の関数は 179 ミリ秒で、最適化されたバージョンは 164 ミリ秒となります。
事前バインディングを使用する
第 2 の問題は、オブジェクトと型キャストに関係します。Visual Basic 6 はオブジェクトのキャストをサポートするために、背後でかなりの作業を行っていますが、多くのプログラマはこれに気づいていません。Visual Basic .NET では、この分野でパフォーマンスをかなり改善することができます。コンパイルを行うときは事前バインディングを使用してください。これにより、コンパイラに対して、型の強制変換は明示的に指定されたときにのみ行うよう指示することができます。これには主に 2 つの効果があります。
- 奇妙なエラーの追跡が簡単になる。
- 不要な強制型変換が削除され、パフォーマンスが大幅に改善される。
オブジェクトを別の型であるかのように扱うと、Visual Basic はプログラマが指定していない限り、オブジェクトを自動的に強制変換します。プログラマはコードについてそれほど考えなくても済むため、これは便利な機能です。しかし、このような強制変換は予期しない動作をすることがあり、プログラマはそれをまったく制御できないという欠点があります。
遅延バインディングを使用しなくてはならないケースもありますが、判断がつかないほとんどのケースでは、事前バインディングを使用することができます。Visual Basic 6 プログラマにとっては、これまでよりも型について考えなくてはならないので、最初のうちは面倒かもしれません。しかし新規のプログラマにとっては簡単なことであり、Visual Basic 6 に慣れていた人もすぐに習得できると思われます。
Option Strict および Explicit をオンにする
Option Strict をオンにすることで、意図しない遅延バインディングを防ぎ、コーディングの規準を高めることができます。Option Strict で適用される制約のリストについては、MSDN Library を参照してください。注意しなくてはならないのは、範囲を狭める形での型の強制変換は必ず明示的に指定されなくてはならないということです。ただし、この作業を通して、他の部分に意図から外れた作業を行っているコードが存在することに気づき、いくつかのバグを修正できるかもしれないという利点があります。
Option Explicit は Option Strict ほど制約が厳しくありませんが、やはりプログラマに対して、コード内に通常よりも多くの情報を指定するよう求めます。具体的には、変数を使用する前に、その変数を宣言する必要があります。これにより、型の推測が実行時ではなくコンパイル時に行われるようになります。そしてチェックが行われなくなった分だけ、パフォーマンスが向上します。
筆者は、まず Option Explicit から始めて、後に Option Strict をオンにするようお勧めします。これにより、多数のコンパイル エラーに惑わされずに済み、徐々に厳密な環境へと移行していくことができます。両方のオプションを使用する環境では、アプリケーションのパフォーマンスを最大限に高めることができます。
テキストではバイナリ比較を使用する
テキストを比較するときには、テキスト比較ではなくバイナリ比較を使用します。実行時のオーバーヘッドは、バイナリ比較の方がはるかに小さくなっています。
format() の使用を最小限に抑える
可能ならば、format() の代わりに toString() を使用します。ほとんどの場合は、はるかに小さいオーバーヘッドで、必要な機能を実現することができます。
charw を使用する
char の代わりに charw を使用します。CLR は内部的に Unicode を使用しており、char を使用している場合には、実行時に char への変換を行わなくてはなりません。これはパフォーマンスを大幅に低下させかねませんが、(charw を使って) 文字がフル ワードの長さであることを指定すれば、変換は行われなくなります。
代入を最適化する
exp = exp + val ではなく exp += val を使用します。exp は複雑な内容になることがあり、不要な作業が大量に行われる可能性があります。このとき、JIT は exp の両方のコピーを評価しなくてはなりませんが、多くの場合、そのような作業は不要です。exp += val であれば、JIT は exp を 2 度評価しなくて済むので、exp = exp + val よりもはるかに高度な最適化が可能となります。
不要なインダイレクションを避ける
byRef を使用すると、実際のオブジェクトではなくポインタを渡すことになります。これは多くのケースで有効ですが (副作用を持つ関数など)、必ずしも必要ではない場合もあります。ポインタの受け渡しを行うと、インダイレクションが増え、スタック上に存在する値にアクセスするときよりも速度が低下します。ヒープの内容を処理する必要がない場合は、避けるのが一番です。
連結は 1 つの式にまとめる
複数の行に複数の連結がある場合は、1 つの式にまとめるようにします。コンパイラは文字列をその場で変更することで最適化を行い、速度とメモリの点でコードを改善することができます。しかし、文が複数の行に分割されていると、Visual Basic コンパイラはその場での連結が可能な Microsoft Intermediate Language (MSIL) を生成しません。前に示した StringBuilder の例を参照してください。
Return 文を入れる
Visual Basic では、関数が return 文を使わずに値を返すことができます。Visual Basic .NET もこれをサポートしていますが、return が明示的に使用されていれば、JIT はより多くの最適化を行うことができます。return 文がない場合には、キーワードなしの値のリターンを透過的にサポートするために、個々の関数に対してスタック上にいくつかのローカル変数が与えられます。これらの変数を保持したままにすると、JIT による最適化が難しくなり、コードのパフォーマンスに影響が及ぶことがあります。自分の関数を見直して、必要に応じて return を挿入するようにしてください。コードのセマンティクスを変えないまま、アプリケーションの速度を高めることができます。
Managed C++ での移植と開発のためのヒント
Microsoft は、Managed C++ (MC++) を特殊な種類の開発者をターゲットとして設計しています。MC++ はあらゆる仕事に最適のツールというわけではありません。このドキュメントを読み終えた読者は、C++ が最高のツールではなく、トレードオフのコストはその利点よりも大きいと判断するかもしれません。確信できない場合は、決定を下す上で参考になる リソースが他にも多数存在します。このセクションは、すでに何らかの形で MC++ を使うことを決めており、そのパフォーマンス上の側面について知りたいと考えている開発者を対象としています。
C++ 開発者にとって、Managed C++ を使用する際にはいくつかの決定を下す必要があります。やろうとしているのは、古いコードの移植なのでしょうか? もしそうならば、コード全体をマネージドの世界に移植するのか、それともラッパーをインプリメントする予定があるのでしょうか? ここでは、プログラマがパフォーマンスの違いを最も大きく感じることになる、「全体を移植する」、あるいは MC++ のコードをゼロから書くというシナリオに焦点を当てることにします。
マネージドの世界の利点
Managed C++ の最も強力な機能は、マネージド コードとアンマネージド コードを式のレベルで混在させることができるということです。このようなことは他の言語では不可能であり、適切に使用すればいくつかの強力な利点を引き出すことができます。これについては、後にいくつかの例を挙げて説明します。
また、マネージドの世界では、多くの一般的な問題が自動的に処理されるという意味で、設計の段階で大きな恩恵を受けることができます。メモリ管理、スレッド スケジューリング、および型の強制変換などは、望むならばランタイムに任せてしまい、プログラマはプログラムの本来の部分にエネルギーを注ぐことができます。MC++ では、プログラマがどの部分までをコントロールするかを厳密に選ぶことができます。
MC++ プログラマには、IL にコンパイルするために Microsoft Visual C++® .NET バックエンドを使用し、その上で JIT を使用するという贅沢が許されています。Microsoft C++ コンパイラを使い慣れているプログラマは、高速で動作するコードに慣れています。一方、JIT はそれとは別の目標に沿って設計されており、異なる長所と弱点のセットを持っています。Visual C++ .NET コンパイラは、JIT の時間的な制約に縛られず、プログラム全体の解析、より積極的なインライン展開、エンレジストレーションといった、JIT には不可能な最適化を行うことができます。また、タイプセーフな環境でしか行えない最適化もいくつかあるため、C++ 以上に高速化の余地が存在します。
JIT では優先順位が異なるため、一部の操作は以前よりも高速になり、一部の操作は低速になります。安全性および言語の柔軟性との間のトレードオフが存在しており、そのうちのいくつかは高コストです。幸いなことに、プログラマはいくつかの手段で、このコストを最小限に抑えることができます。
移植: すべての C++ コードは MSIL にコンパイルできる
先に進む前に、任意の C++ コードを MSIL にコンパイルできるということに注意してください。すべてのコードが動作しますが、タイプ セーフティの保証はなく、相互運用を頻繁に行っている場合にはマーシャリングのペナルティが発生します。では、利点を享受できないのであれば、MSIL にコンパイルする理由はどこにあるのでしょうか? それは、大きなコード ベースを移植しようとしているときには、コードを断片ごとに徐々に移植していくことができるということです。MC++ を使用すれば、移植済みのコードと未移植のコードをつなぎ合わせる特殊なラッパーの作成にかける時間を、より多くのコードの移植に費やすことができ、これは最終的には大きな利点となります。アプリケーションの移植はきわめてクリーンなプロセスとなります。C++ の MSIL へのコンパイルについては、/clr コンパイラ オプションの項を参照してください。
ただし、単に C++ コードを MSIL にコンパイルしただけで、マネージドの世界のセキュリティと柔軟性の恩恵を受けられるわけではありません。コードは MC++ で書く必要があり、v1 ではいくつかの機能を諦める必要があります。次のリストは、現行バージョンの CLR ではサポートされていない機能を示していますが、将来のバージョンではサポートされる可能性があります。Microsoft は、一般的な機能を先にサポートし、それほど頻繁に使われない機能を削除して、最初のバージョンを配布することに決定しました。後にこれらが追加されない理由はありませんが、それまではこれらの機能なしでコーディングを行う必要があります。
- 多重継承
- テンプレート
- 決定論的なファイナライゼーション
これらの機能が必要な場合には、いつでもアンセーフなコードとの相互運用を行うことができますが、やり取りされるデータのマーシャリングのためにパフォーマンス上のペナルティが課せられます。また、これらの機能はアンマネージド コードの中でしか使用できないことに注意してください。マネージドの空間はそれらの存在についての知識を持っていません。コードの移植を行うかどうかを決めるときには、そのデザインの中でこれらの機能にどれほど依存しているかを考えてください。ケースによっては、再設計にはコストがかかりすぎるので、アンマネージド コードのまま使い続けた方がいい場合があります。これは、コーディングを始める前に行わなくてはならない最初の意思決定です。
C# または Visual Basic と比べたときの MC++ の利点
アンマネージドの世界をバックグラウンドとする MC++ は、アンセーフ コードを扱う能力を数多く残しています。MC++ のマネージド コードとアンマネージド コードをスムーズに混在させることができる能力により、開発者はより強いパワーを行使することができ、コードを書くときには、どのぐらいの配分で混在させるかを自分で選ぶことができます。極端なケースでは、すべてのものを単純な C++ で書き、単に /clr を付けてコンパイルすることができます。その逆の場合では、すべてのものをマネージド オブジェクトとして作成し、前に述べた言語の制限とパフォーマンスの問題に対処することができます。
しかし、MC++ の真のパワーは、この 2 つの極端なケースのどこか中間で発揮されます。MC++ では、アンセーフな機能を使用する箇所を細かく制御することで、マネージド コードでは避けられないいくつかのパフォーマンス上の弱点を回避することができます。C# はこの機能の一部を unsafe キーワードの形で持っていますが、これは言語に統合された機能ではなく、その有効性は MC++ よりもはるかに低くなっています。以下の項では、MC++ で可能な細かいレベルでの制御の例をいくつか示し、これらが有効な状況について説明します。
一般化された "byref" ポインタ
C# では、クラスの一部のメンバのアドレスは、それを ref パラメータに渡すことによってしか取得することができません。MC++ では、byref ポインタは正当な構文です。配列の中間にある項目のアドレスを取得し、そのアドレスを関数から返すことができます。
Byte* AddrInArray( Byte b[] ) {
return &b[5];
}
この機能を利用して、ヘルパー ルーチンを介して System.String の中の「文字」へのポインタを返しています。これらのポインタを使って、配列をループ処理することもできます。
System::Char* PtrToStringChars(System::String*);
for( Char*pC = PtrToStringChars(S"boo");
pC != NULL;
pC++ )
{
... *pC ...
}
また、MC++ を利用すれば、"next" のフィールドのアドレスを取得することで、リンク リストを渡り歩くこともできます (これは C# では不可能です)。
Node **w = &Head;
while(true) {
if( *w == 0 || val < (*w)->val ) {
Node *t = new Node(val,*w);
*w = t;
break;
}
w = &(*w)->next;
}
C# では、"Head" をポイントしたり、"next" のフィールドのアドレスを取得することはできないので、最初の位置に挿入するときや、"Head" が null の場合には特殊なケースとして処理しなくてはなりません。さらに、コード中ではつねに 1 つ先のノードを参照している必要があります。次の C# で書かれたコードと比較してみてください。
if( Head==null || val < Head.val ) {
Node t = new Node(val,Head);
Head = t;
}else{
// 少なくとも 1 つのノードが存在するので
// 1 つ先のノードを先読みすることができる
Node w=Head;
while(true) {
if( w.next == null || val < w.next.val ){
Node t = new Node(val,w.next.next);
w.next = t;
break;
}
w = w.next;
}
}
ボックス化された型へのユーザー アクセス
OO 言語に共通するパフォーマンス上の問題は、値のボックス化とアンボックス化に費やされる時間です。MC++ ではこの動作を細かく制御することができるため、値にアクセスするために動的に (または静的に) アンボックス化を行う必要がありません。これはもう 1 つのパフォーマンス上の利点です。任意の型の前に __box キーワードを付けるだけで、そのボックス化された形式を表現することができます。
__value struct V {
int i;
};
int main() {
V v = {10};
__box V *pbV = __box(v);
pbV->i += 10; // キャストなしに更新
}
C# では、"v" にアンボックス化した後に、値を更新し、再び Object にボックス化する必要があります。
struct B { public int i; }
static void Main() {
B b = new B();
b.i = 5;
object o = b; // 暗黙のボックス化
B b2 = (B)o; // 明示的なアンボックス化
b2.i++; // 更新
o = b2; // 暗黙の再ボックス化
}
STL コレクションとマネージド コレクション - v1
悪いニュース: C++ では、STL コレクションを使用すると、一般にその機能を手作業で書いたときと同じほどの速度が出ていました。CLR フレームワークはきわめて高速ですが、ボックス化とアンボックス化の問題にさらされています。すべてのものがオブジェクトであり、テンプレートやジェネリック サポートがない限り、すべてのアクションが実行時にチェックされなくてはなりません。
よいニュース: 長期的には、ジェネリックがランタイムに追加され、この問題は消えるものと予想されます。今日導入したコードは、変更なしに速度が向上することになります。短期的には、静的なキャストを使ってチェックを回避することができますが、これは安全な操作ではなくなっています。この手法は、パフォーマンスが絶対的に重要なタイトなコードで、2 つまたは 3 つのホット スポットが識別できたときにのみ使用することをお勧めします。
スタック管理オブジェクトを使用する
C++ では、プログラマがオブジェクトをスタックとヒープのどちらで管理するかを指定します。MC++ でもこれは可能ですが、注意しておかなくてはならない制約があります。CLR はすべてのスタック管理オブジェクトに ValueType を使用しており、ValueType が行えることには制限があります (たとえば継承はできません)。詳細については、MSDN Library を参照してください。
稀なケース: マネージド コード内の間接的な呼び出しに注意 - v1
v1 ランタイムでは、すべての間接的な関数呼び出しはネイティブに行われ、したがってアンマネージド スペースへの移行が必要となります。すべての間接的な関数呼び出しは、ネイティブ モードからしか行えません。つまり、マネージド コードからのすべての間接的な呼び出しは、マネージドからアンマネージドへの移行を必要とします。これは、テーブルがマネージド関数を返すときには深刻な問題となります。なぜならば、関数を実行するためには、第 2 の移行を行う必要があるからです。1 つの Call 命令を実行する場合と比べると、C++ では実行速度が 50〜100 倍も遅くなります!
幸いなことに、ガーベジ コレクションが行われるクラスの中に含まれているメソッドを呼び出した場合には、最適化がこの処理を削除します。しかし、/clr を使ってコンパイルされた通常の C++ ファイルのケースでは、メソッドからの戻りはマネージドと見なされます。これは最適化によっては削除できないので、二重の移行に要するコストがまともにかかってくることになります。次にこのようなケースの例を示します。
//////////////////////// a.h: //////////////////////////
class X {
public:
void mf1();
void mf2();
};
typedef void (X::*pMFunc_t)();
////////////// a.cpp: /clr を付けてコンパイル /////////////////
#include "a.h"
int main(){
pMFunc_t pmf1 = &X::mf1;
pMFunc_t pmf2 = &X::mf2;
X *pX = new X();
(pX->*pmf1)();
(pX->*pmf2)();
return 0;
}
////////////// b.cpp: /clr なしでコンパイル /////////////////
#include "a.h"
void X::mf1(){}
////////////// c.cpp: /clr を付けてコンパイル ////////////////////
#include "a.h"
void X::mf2(){}
これを避ける方法はいくつかあります。
- クラスをマネージド クラスにする ("__gc")
- 可能ならば間接的な呼び出しを削除する
- クラスをアンマネージド コードのままとしてコンパイルする (/clr を使わない、など)
パフォーマンス ヒットの最小化 - バージョン 1
バージョン 1 の JIT では、MC++ を使うとより大きなコストがかかる操作や機能がいくつか存在します。以下に、これらを機能と簡単な解説を示し、その後にどのような対策が可能かを説明します。
- 抽象化—これは、巨大で低速な C++ バックエンド コンパイラが JIT に圧勝する分野です。抽象化の目的のために、int をクラスの中にラップし、これに厳密に int としてアクセスすると、C++ はラッパーのオーバーヘッドを実質的にゼロにまで減らすことができます。抽象化のレベルは、コストを増やさずにいくらでも追加することができます。一方、JIT にはこのコストをなくすための時間的余裕がないため、MC++ では深いレベルの抽象化は高コストになっています。
- 浮動小数点—v1 JIT は VC++ バックエンドが持っている FP 固有の最適化をすべては実行しないため、現時点では浮動小数点演算が高コストになっています。
- 多次元配列—JIT は多次元配列よりも jagged 配列の処理に秀でているので、代わりに jagged 配列を使用するようにしてください。
- 64 ビット算術—将来のバージョンの JIT では、64 ビットの最適化が追加される予定です。
対処方法
プログラマは、開発のすべての段階で、いくつかの対処を行うことができます。MC++ では、どれだけの処理を自分で行い、その見返りにパフォーマンスがどれほど改善されるかが決定される設計フェーズが、おそらく最も重要な段階と言えるでしょう。机に向かってアプリケーションの作成または移植を行うときには、以下の点を考慮に入れる必要があります。
- 多重継承、テンプレート、または決定論的なファイナライゼーションを使用している部分を識別します。これらは削除するか、コードのその部分はアンマネージドのまま残すことになります。再設計のコストを検討し、移植可能な部分を識別してください。
- 深いレベルの抽象化や、マネージド スペースへの仮想関数呼び出しなどの、パフォーマンス上のホット スポットを探し出します。設計上の意思決定も必要となります。
- スタックによる管理が指定されているオブジェクトを探します。これらが ValueType に変換できることを確認します。その他のものは、ヒープ管理オブジェクトに変換する対象としてマークします。
コーディングの段階では、コストが比較的高い操作と、それに対処するための選択肢を意識します。MC++ のよい点の 1 つは、コーディングを始める前から、パフォーマンス上のすべての問題を把握できるということです。これは、後に実際の仕事を行うときに役立ちます。ただし、コーディングとデバッギングの際に行える微調整もいくつかあります。
浮動小数点算術、多次元配列、またはライブラリ関数を多用している部分を突き止めます。このうちのどれが、パフォーマンスに大きな影響を与えるかを判断します。プロファイラを使って、オーバーヘッドのコストが最も高い部分を選び出し、最良の対処方法を決定します。
- その部分全体をアンマネージドのまま残す。
- ライブラリ アクセスに静的キャストを適用する。
- ボックス化/アンボックス化の動作に調整を加える (後述)。
- 独自の構造をコーディングする。
最後に、移行の回数を最小限に抑えるよう努力してください。ループ内にアンマネージド コードや interop 呼び出しが含まれている場合には、ループ全体をアンマネージドにします。これにより、ループの繰り返しのたびに移行を行うのではなく、移行のコストを 2 回支払うだけで済みます。
関連リソース
.NET Framework におけるパフォーマンスについての関連トピックには、以下のものがあります。
現在執筆中の今後の記事では、設計、アーキテクチャ、およびコーディング思想の概要、マネージドの世界におけるパフォーマンス分析ツールのウォークスルー、および .NET と今日の市販の他のエンタープライズ アプリケーションとのパフォーマンスの比較などを取り上げる予定です。
付録: 仮想呼び出しと割り当てのコスト
| 呼び出しのタイプ |
1 秒当たりの呼び出しの回数 |
| ValueType Non-Virtual Call |
809971805.600 |
| Class Non-Virtual Call |
268478412.546 |
| Class Virtual Call |
109117738.369 |
| ValueType Virtual (Obj Method) Call |
3004286.205 |
| ValueType Virtual (Overridden Obj Method) Call |
2917140.844 |
| Load Type by Newing (Non-Static) |
1434.720 |
| Load Type by Newing (Virtual Methods) |
1369.863 |
注 テストに使われたのは、Windows 2000 Professional と Service Pack 2 を実行している PIII 733Mhz マシンです。
このチャートは、各種のメソッド呼び出しに付随するコストと、仮想メソッドを含んでいる型をインスタンス作成するコストを比較しています。大きい値ほど、1 秒当たりに多くの呼び出しまたはインスタンス作成を行えることを示しています。これらの数値はマシンと構成によって変わりますが、1 回の呼び出しを行うコストの相対的な関係は保たれます。
- ValueType Non-Virtual Call: このテストは、ValueType 内に含まれた空の非仮想メソッドを呼び出しています。
- Class Non-Virtual Call: このテストは、クラス内に含まれた空の非仮想メソッドを呼び出しています。
- Class Virtual Call: このテストは、クラス内に含まれた空の仮想メソッドを呼び出しています。
- ValueType Virtual (Obj Method) Call: このテストは、ValueType に対して ToString() (仮想メソッド) を呼び出しています。デフォルトのオブジェクト メソッドが使用されます。
- ValueType Virtual (Overridden Obj Method) Call: このテストは、ValueType に対して ToString() (仮想メソッド) を呼び出しています。デフォルトはオーバーライドされています。
- Load Type by Newing (Static): このテストは、スタティック メソッドのみを持つクラスのスペースを割り当てます。
- Load Type by Newing (Virtual Methods): このテストは、仮想メソッドを持つクラスのスペースを割り当てます。
ここから引き出せる結論の 1 つに、クラス内のメソッドを呼び出すときには、仮想関数呼び出しに通常の呼び出しの約 2 倍のコストがかかるというものがあります。もちろん、呼び出しそのものはそもそも低コストであることに注意してください。筆者ならば、すべての仮想呼び出しを削除するというようなことはしません。妥当な理由があるときには、仮想メソッドを使用するようにすべきです。
- JIT は仮想メソッドをインライン展開できないので、非仮想メソッドを削除すると、最適化のチャンスが失われます。
- 仮想メソッドを持つオブジェクトのスペースの割り当てが、仮想メソッドを持たないオブジェクトの割り当てよりも若干遅いのは、仮想テーブルのためのスペースを探す余分な作業が必要となるためです。
ValueType 内の非仮想メソッドの呼び出しはクラス内の非仮想メソッドの呼び出しの 3 倍以上も高速ですが、いったんこれをクラスとして扱うと、パフォーマンスは大幅に低下します。構造体のように扱えば超高速だが、クラスのように扱うときわめて遅くなるということが、ValueType の持つ特性なのです。ToString() は仮想メソッドなので、これを呼び出す前に、構造体をヒープ上のオブジェクトに変換する必要があります。このため、ValueType に対して仮想メソッドを呼び出すと、速度は 2 倍どころか 18 倍にも落ちています ! このストーリーの教訓は、ValueType をクラスとして扱ってはならないということです。
|