マネージド アプリケーションHigh-Performance記述する : 入門

 

グレゴ・ノリスキン
Microsoft CLR パフォーマンス チーム

2003 年 6 月

適用対象:
   Microsoft® .NET Framework

概要:パフォーマンスの観点から、.NET Frameworkの共通言語ランタイムについて説明します。 マネージド コードのパフォーマンスのベスト プラクティスを特定する方法と、マネージド アプリケーションのパフォーマンスを測定する方法について説明します。 (19ページ印刷)

CLR Profiler をダウンロードします。 (330 KB)

内容

ソフトウェア開発のメタファーとしてのジャグリング
.NET 共通言語ランタイム
マネージド データとガベージ コレクター
割り当てプロファイル
プロファイリング API と CLR プロファイラー
サーバー GC のホスト
終了
Dispose パターン
弱い参照に関するメモ
マネージド コードと CLR JIT
値型
例外処理
スレッド処理と同期
リフレクション
遅延バインディング
セキュリティ
COM 相互運用機能とプラットフォーム呼び出し
パフォーマンス カウンター
その他のツール
まとめ
リソース

ソフトウェア開発のメタファーとしてのジャグリング

ジャグリングは、ソフトウェア開発プロセスを記述するための優れたメタファーです。 ジャグリングには通常、少なくとも 3 つの項目が必要ですが、ジャグリングを試みることができる項目の数に上限はありません。 あなたがジャグリングする方法を学び始めると、キャッチして投げるとき、各ボールを個別にwatchことがわかります。 進むにつれて、個々のボールではなく、ボールの流れに集中し始めます。 ジャグリングをマスターしたら、もう一度単一のボールに集中し、鼻の上でそのボールのバランスを取りながら、他のボールをジャグリングし続けることができます。 あなたはボールがどこにあるかを直感的に知っており、それらをキャッチして投げるために適切な場所に手を置くことができます。 では、これはソフトウェア開発とどのように似ていますか?

ソフトウェア開発プロセスにおける異なる役割は、異なる「三位一体」をジャグリングします。プロジェクトマネージャーとプログラムマネージャーは、機能、リソース、時間、ソフトウェア開発者が正確性、パフォーマンス、セキュリティを両立させます。 1つは常により多くのアイテムをジャグリングしようとすることができますが、ジャグリングの学生が証明できるので、1つのボールを追加すると、ボールを空中に保つのが指数関数的に困難になります。 技術的には、3つ未満のボールをジャグリングしている場合は、まったくジャグリングしていません。 ソフトウェア開発者として、記述しているコードの正確性、パフォーマンス、セキュリティを考慮していない場合は、ジョブを実行していないケースを作成できます。 最初に正確性、パフォーマンス、セキュリティの検討を始めると、一度に 1 つの側面に集中する必要があります。 日常の練習の一部になると、特定の側面に集中する必要はなくなり、単に作業方法の一部になります。 あなたがそれらを習得したら、直感的にトレードオフを行い、適切にあなたの努力に集中することができます。 ジャグリングと同様に、練習が鍵です。

高性能コードの記述には、それ自体の三位一体があります。目標の設定、測定、およびターゲット プラットフォームの理解。 コードの速度がわからない場合は、完了したらどのようにわかりますか? コードを測定してプロファイリングしない場合、目標を達成したタイミングや目標を達成していない理由を知る方法は何ですか? ターゲットとするプラットフォームがわからない場合は、目標を達成していない場合に最適化する方法を知る方法について説明します。 これらの原則は、一般的に、ターゲットとするプラットフォームを問わず、高パフォーマンス コードの開発に適用されます。 この三位一体に言及しないと、高性能コードの記述に関する記事は完成しません。 3 つとも同様に重要ですが、この記事では、Microsoft® .NET Frameworkを対象とする高性能アプリケーションの作成に適用される後者の 2 つの側面に焦点を当てます。

任意のプラットフォームでハイ パフォーマンス コードを記述する基本的な原則は次のとおりです。

  1. パフォーマンス目標の設定
  2. もう少し測定、測定、測定
  3. アプリケーションがターゲットとするハードウェアとソフトウェアのプラットフォームを理解する

.NET 共通言語ランタイム

.NET Frameworkの中核となるのは、共通言語ランタイム (CLR) です。 CLR は、コードのすべてのランタイム サービスを提供します。Just-In-Time コンパイル、メモリ管理、セキュリティ、その他多数のサービス。 CLR は、高パフォーマンスを実現するように設計されています。 つまり、そのパフォーマンスを利用する方法と、それを妨げる方法があります。

この記事の目的は、パフォーマンスの観点から共通言語ランタイムの概要を説明し、マネージド コードのパフォーマンスのベスト プラクティスを特定し、マネージド アプリケーションのパフォーマンスを測定する方法を示すことです。 この記事では、.NET Frameworkのパフォーマンス特性に関する包括的な説明ではありません。 この記事では、スループット、スケーラビリティ、起動時間、メモリ使用量を含むようにパフォーマンスを定義します。

マネージド データとガベージ コレクター

パフォーマンスクリティカルなアプリケーションでマネージド コードを使用する際の開発者の主な懸念事項の 1 つは、ガベージ コレクター (GC) によって実行される CLR のメモリ管理のコストです。 メモリ管理のコストは、型のインスタンスに関連付けられているメモリの割り当てコスト、インスタンスの有効期間中にそのメモリを管理するコスト、不要になったときにそのメモリを解放するコストの関数です。

通常、管理された割り当ては非常に安価です。ほとんどの場合、C/C++ malloc または newよりも短い時間を取る必要があります。 これは、CLR が空きリストをスキャンして、新しいオブジェクトを保持するのに十分な大きさの次に使用可能な連続したメモリ ブロックを見つける必要がないためです。メモリ内の次の空き位置へのポインターを保持します。 マネージド ヒープの割り当ては、"スタックのような" と考えることができます。 GC が新しいオブジェクトを割り当てるためにメモリを解放する必要がある場合、割り当てによってコレクションが発生する可能性があります。この場合、割り当ては または newよりもmallocコストが高くなります。 固定されたオブジェクトは、割り当てコストにも影響を与える可能性があります。 固定オブジェクトは、GC がコレクション中に移動しないように指示されたオブジェクトです。通常は、オブジェクトのアドレスがネイティブ API に渡されているためです。

mallocまたは newとは異なり、オブジェクトの有効期間にわたってメモリを管理することに関連するコストがあります。 CLR GC は世代別です。つまり、ヒープ全体が常に収集されるとは限りません。 ただし、収集されるヒープの部分にあるヒープ ルート オブジェクトの残りの部分に存在するライブ オブジェクトがあるかどうかを GC は引き続き認識する必要があります。 若い世代のオブジェクトへの参照を保持するオブジェクトを含むメモリは、オブジェクトの有効期間にわたって管理するためにコストがかかります。

GC はジェネレーション マークとスイープ ガベージ コレクターです。 マネージド ヒープには 3 つの世代が含まれています。第 0 世代にはすべての新しいオブジェクトが含まれます。第 1 世代には、もう少し有効期間の長いオブジェクトが含まれています。第 2 世代には有効期間の長いオブジェクトが含まれています。 GC は、アプリケーションが続行するのに十分なメモリを解放するために、ヒープの最小セクションを収集します。 Generation のコレクションには、すべての若い世代のコレクションが含まれます。この場合、Generation 1 コレクションも Generation 0 を収集します。 ジェネレーション 0 は、プロセッサのキャッシュのサイズとアプリケーションの割り当て率に応じて動的にサイズ設定され、通常、収集にかかる時間は 10 ミリ秒未満です。 ジェネレーション 1 は、アプリケーションの割り当て率に応じて動的にサイズ設定され、通常は収集に 10 から 30 ミリ秒かかります。 第 2 世代のサイズは、収集にかかる時間と同様に、アプリケーションの割り当てプロファイルによって異なります。 アプリケーションのメモリ管理のパフォーマンス コストに最も大きな影響を与えるのは、これらの第 2 世代コレクションです。

ヒント GC は自己チューニングであり、アプリケーションのメモリ要件に応じて調整されます。 ほとんどの場合、プログラムによって GC を呼び出すと、そのチューニングが妨げられる可能性があります。 GC を呼び出して GC を "支援" します。収集 すると、アプリケーションのパフォーマンスが向上しない可能性が高くなります。

GC は、コレクション中にライブ オブジェクトを再配置できます。 これらのオブジェクトが大きい場合、再配置のコストが高いため、これらのオブジェクトは、ラージ オブジェクト ヒープと呼ばれるヒープの特別な領域に割り当てられます。 ラージ オブジェクト ヒープは収集されますが、圧縮されません。たとえば、ラージ オブジェクトは再配置されません。 大きなオブジェクトは、80 kb を超えるオブジェクトです。 これは、CLR の将来のバージョンで変更される可能性があることに注意してください。 ラージ オブジェクト ヒープを収集する必要がある場合は、完全なコレクションが強制され、Gen 2 コレクション中にラージ オブジェクト ヒープが収集されます。 ラージ オブジェクト ヒープ内のオブジェクトの割り当てとデス レートは、アプリケーション のメモリ管理のパフォーマンス コストに大きな影響を与える可能性があります。

割り当てプロファイル

マネージド アプリケーションの全体的な割り当てプロファイルでは、アプリケーションに関連付けられているメモリを管理するためにガベージ コレクターが機能する必要があるハードを定義します。 GC でメモリの管理が困難になるほど、GC にかかる CPU サイクルの数が多くなり、CPU がアプリケーション コードの実行に費やす時間が短くなります。 割り当てプロファイルは、割り当てられたオブジェクトの数、それらのオブジェクトのサイズ、およびその有効期間の関数です。 GC の負荷を軽減する最も明白な方法は、割り当てるオブジェクトの数を減らすだけです。 オブジェクト指向設計手法を使用して拡張性、モジュール性、再利用のために設計されたアプリケーションは、ほとんどの場合、割り当ての数が増加します。 抽象化と "優雅さ" にはパフォーマンスの低下があります。

GC に適した割り当てプロファイルには、アプリケーションの開始時に一部のオブジェクトが割り当てられ、アプリケーションの有効期間中は存続し、その後、他のすべてのオブジェクトが短命になります。 有効期間の長いオブジェクトには、有効期間の短いオブジェクトへの参照がほとんどまたはまったく含まれない。 割り当てプロファイルがこのプロファイルから逸脱するにつれて、GC はアプリケーションのメモリを管理するためにより困難な作業を行う必要があります。

GC 非フレンドリ割り当てプロファイルでは、第 2 世代まで存続した後に死んでも多くのオブジェクトが存在するか、Large Object Heap に割り当てられる有効期間の短いオブジェクトが多数存在します。 第 2 世代に入ってから死ぬほど長く存続するオブジェクトは、管理に最もコストがかかります。 GC 中の若い世代のオブジェクトへの参照を含む古い世代のオブジェクトについても前に説明したように、コレクションのコストも増加します。

一般的な実際の割り当てプロファイルは、上記の 2 つの割り当てプロファイルの間のどこかに存在します。 割り当てプロファイルの重要なメトリックは、GC で費やされている CPU 時間の合計に対する割合です。 この数値は、 .NET CLR Memory: % Time in GC パフォーマンス カウンターから取得できます。 このカウンターの平均値が 30% を超える場合は、割り当てプロファイルを詳しく調べてみてください。 これは、割り当てプロファイルが "不適切" であることを必ずしも意味するとは限りません。このレベルの GC が必要で適切なメモリ負荷の高いアプリケーションがいくつかあります。 このカウンターは、パフォーマンスの問題が発生した場合に最初に確認する必要があります。割り当てプロファイルが問題の一部であるかどうかをすぐに表示されます。

ヒント .NET CLR Memory: % Time in GC パフォーマンス カウンターが、アプリケーションが GC で平均 30% を超える時間を費やしていることを示している場合は、割り当てプロファイルを詳しく確認する必要があります。

ヒント GC に優しいアプリケーションでは、第 2 世代コレクションよりもジェネレーション 0 が大幅に多くなります。 この比率を確立するには、NET CLR メモリ : # Gen 0 コレクションと NET CLR メモリ: # Gen 2 コレクションパフォーマンス カウンターを比較します。

プロファイリング API と CLR プロファイラー

CLR には、サード パーティがマネージド アプリケーション用のカスタム プロファイラーを記述できる強力なプロファイリング API が含まれています。 CLR Profiler は、このプロファイリング API を使用する CLR 製品チームによって作成された、サポートされていない割り当てプロファイル サンプル ツールです。 CLR Profiler を使用すると、開発者は管理アプリケーションの割り当てプロファイルを確認できます。

図 1 CLR プロファイラーのメイン ウィンドウ

CLR Profiler には、割り当てプロファイルの非常に便利なビューが多数含まれています。これには、割り当てられた型のヒストグラム、割り当てグラフと呼び出しグラフ、さまざまな世代の GC と、それらのコレクションの後のマネージド ヒープの結果の状態を示すタイム ライン、メソッドごとの割り当てとアセンブリの読み込みを示す呼び出しツリーが含まれます。

図 2 CLR プロファイラーの割り当てグラフ

ヒント CLR Profiler の使用方法の詳細については、zip に含まれている readme ファイルを参照してください。

CLR Profiler には高パフォーマンスのオーバーヘッドがあり、アプリケーションのパフォーマンス特性が大幅に変更されることに注意してください。 CLR Profiler を使用してアプリケーションを実行すると、新たなストレス バグが消える可能性があります。

サーバー GC のホスト

CLR には、ワークステーション GC とサーバー GC という 2 つの異なるガベージ コレクターを使用できます。 コンソールアプリケーションとWindows フォームアプリケーションはワークステーションGCをホストし、ASP.NET はサーバーGCをホストします。 サーバー GC は、スループットとマルチプロセッサのスケーラビリティのために最適化されています。 サーバー GC は、コレクション全体でマネージド コードを実行しているすべてのスレッド (Mark フェーズと Sweep フェーズの両方を含む) を一時停止し、GC は、専用の優先度の高い CPU アフィニティスレッドのプロセスで使用可能なすべての CPU で並列に実行されます。 スレッドが GC 中にネイティブ コードを実行している場合、それらのスレッドは、ネイティブ呼び出しが返されたときにのみ一時停止されます。 マルチプロセッサ マシンで実行するサーバー アプリケーションを構築する場合は、サーバー GC を使用することを強くお勧めします。 アプリケーションが ASP.NET によってホストされていない場合は、CLR を明示的にホストするネイティブ アプリケーションを作成する必要があります。

ヒント スケーラブルなサーバー アプリケーションを構築する場合は、サーバー GC をホストします。 「マネージド アプリのカスタム共通言語ランタイム ホストを実装する」を参照してください

ワークステーション GC は、クライアント アプリケーションに通常必要な待機時間が短い場合に最適化されています。 通常、クライアントのパフォーマンスは生のスループットではなく、認識されるパフォーマンスによって測定されるため、GC 中にクライアント アプリケーションで顕著な一時停止を行う必要はありません。 ワークステーション GC は同時 GC を実行します。これは、マネージド コードの実行中にマーク フェーズを実行することを意味します。 GC は、スイープ フェーズを実行する必要がある場合にのみ、マネージド コードを実行しているスレッドを一時停止します。 ワークステーション GC では、GC は 1 つのスレッドでのみ実行されるため、1 つの CPU でのみ実行されます。

終了

CLR には、型のインスタンスに関連付けられているメモリが解放される前にクリーンが自動的に実行されるメカニズムが用意されています。 このメカニズムは Finalization と呼ばれます。 通常、Finalization はネイティブ リソースを解放するために使用されます。この場合、オブジェクトによって使用されているデータベース接続またはオペレーティング システム ハンドルが使用されます。

ファイナライズは高価な機能であり、GCに置かれる圧力を高めます。 GC は、Finalizable Queue でファイナライズを必要とするオブジェクトを追跡します。 コレクション中に GC が、有効期限が切れたオブジェクトを見つけたが、ファイナライズが必要な場合、そのオブジェクトの Finalizable Queue 内のエントリは FReachable Queue に移動されます。 ファイナライザー スレッドと呼ばれる別のスレッドでファイナライズが行われます。 ファイナライザーの実行中にオブジェクトの状態全体が必要になる場合があるため、オブジェクトとそのオブジェクトが指すすべてのオブジェクトは、次の世代に昇格されます。 オブジェクト (オブジェクトのグラフ) に関連付けられているメモリは、次の GC 中にのみ解放されます。

解放する必要があるリソースは、可能な限り小さい Finalizable オブジェクトにラップする必要があります。たとえば、クラスでマネージド リソースとアンマネージド リソースの両方への参照が必要な場合は、新しい Finalizable クラスでアンマネージド リソースをラップし、そのクラスをクラスのメンバーにする必要があります。 親クラスを Finalizable にすることはできません。 つまり、アンマネージド リソースを含むクラスのみが昇格されます (アンマネージド リソースを含むクラスの親クラスへの参照を保持していない場合)。 もう 1 つの点に留意すべき点は、Finalization スレッドが 1 つだけあることです。 ファイナライザーによってこのスレッドがブロックされた場合、後続のファイナライザーは呼び出されず、リソースは解放されず、アプリケーションはリークします。

ヒント ファイナライザーはできるだけシンプルにし、ブロックしないでください。

ヒント クリーンアップが必要なアンマネージド オブジェクトのラッパー クラスのみをファイナライズ可能にします。

ファイナライズは、参照カウントの代替手段と考えることができます。 参照カウントを実装するオブジェクトは、参照を持つ他のオブジェクトの数 (非常によく知られている問題につながる可能性があります) を追跡し、参照カウントが 0 のときにリソースを解放できるようにします。 CLR は参照カウントを実装していないため、オブジェクトへの参照が保持されていない場合にリソースを自動的に解放するメカニズムを提供する必要があります。 ファイナライズは、そのメカニズムです。 ファイナライズは通常、クリーンアップを必要とするオブジェクトの有効期間が明示的に不明な場合にのみ必要です。

Dispose パターン

オブジェクトの有効期間が明示的にわかっている場合は、オブジェクトに関連付けられているアンマネージド リソースを一括で解放する必要があります。 これは、オブジェクトの "破棄" と呼ばれます。 Dispose パターンは IDisposable インターフェイスを介して実装されます (ただし、自分で実装するのは簡単です)。 クラスで一括ファイナライズを使用できるようにする場合 (たとえば、クラスのインスタンスを破棄可能にする) 場合は、オブジェクトに IDisposable インターフェイスを実装させ、 Dispose メソッドの実装を提供する必要があります。 Dispose メソッドでは、ファイナライザーにあるのと同じクリーンアップ コードを呼び出し、GC を呼び出してオブジェクトをファイナライズする必要がなくなったことを GC に通知します。SuppressFinalization メソッド。 Dispose メソッドと Finalizer の両方で共通のファイナライザー関数を呼び出して、クリーンアップ コードの 1 つのバージョンのみを維持することをお勧めします。 また、オブジェクトのセマンティクスが 、Close メソッドが Dispose メソッドよりも論理的な場合は、 Close も実装する必要があります。この場合、データベース接続またはソケットが論理的に "閉じられている"。 Close メソッドは、Dispose メソッドを呼び出すだけです。

ファイナライザーを使用してクラスに Dispose メソッドを提供することは常に良い方法です。たとえば、そのクラスの有効期間が明示的に認識されるかどうかなど、そのクラスがどのように使用されるかはわかりません。 使用しているクラスが Dispose パターンを実装していて、オブジェクトの使用が完了したときに明示的にわかっている場合は、最も確実に Dispose を呼び出します。

ヒント ファイナライズ可能なすべてのクラスに対して Dispose メソッドを指定します。

ヒントDispose メソッドの最終処理を抑制します。

ヒント 共通のクリーンアップ関数を呼び出します。

ヒント 使用しているオブジェクトが IDisposable を実装していて、オブジェクトが不要になったことがわかっている場合は、Dispose を呼び出します。

C# では、オブジェクトを自動的に破棄する非常に便利な方法が提供されます。 using キーワード (keyword)を使用すると、コード ブロックを識別でき、その後、破棄可能なオブジェクトの数に対して Dispose が呼び出されます。

C#の using キーワード (keyword)

using(DisposableType T)
{
   //Do some work with T
}
//T.Dispose() is called automatically

弱い参照に関するメモ

スタック上にあるオブジェクト、レジスタ内、別のオブジェクト、または他の GC ルート内にあるオブジェクトへの参照は、GC 中にオブジェクトを維持します。 これは通常、アプリケーションがそのオブジェクトで行われないことを意味することを考慮すると、非常に良いことです。 ただし、オブジェクトへの参照が必要ですが、その有効期間には影響を与えたくない場合があります。 このような場合、CLR は、それを行うための弱い参照と呼ばれるメカニズムを提供します。 任意の厳密な参照 (たとえば、オブジェクトをルートとする参照) は、弱い参照に変換できます。 弱い参照を使用する場合の例として、データ構造を走査できるが、オブジェクトの有効期間には影響しない外部カーソル オブジェクトを作成する場合があります。 もう 1 つの例は、メモリ不足が発生したときにフラッシュされるキャッシュを作成する場合です。たとえば、GC が発生した場合などです。

C での弱い参照の作成#

MyRefType mrt = new MyRefType();
//...

//Create weak reference
WeakReference wr = new WeakReference(mrt); 
mrt = null; //object is no longer rooted
//...

//Has object been collected?
if(wr.IsAlive)
{
   //Get a strong reference to the object
   mrt = wr.Target;
   //object is rooted and can be used again
}
else
{
   //recreate the object
   mrt = new MyRefType();
}

マネージド コードと CLR JIT

マネージド コードの配布単位であるマネージド アセンブリには、Microsoft Intermediate Language (MSIL または IL) と呼ばれるプロセッサに依存しない言語が含まれています。 CLR Just-In-Time (JIT) は、IL を最適化されたネイティブ X86 命令にコンパイルします。 JIT は最適化コンパイラですが、実行時にコンパイルが行われ、メソッドが初めて呼び出されるときにのみ、コンパイルにかかる時間と最適化の数のバランスを取る必要があります。 通常、起動時間と応答性は一般に問題ではないため、サーバー アプリケーションでは重要ではありませんが、クライアント アプリケーションでは重要です。 NGEN.exeを使用してインストール時にコンパイルを行うことで、起動時間を改善できることに注意してください。

JIT によって実行される最適化の多くは、プログラムによるパターンを関連付けません。たとえば、それらのパターンを明示的にコーディングすることはできませんが、実行する数があります。 次のセクションでは、これらの最適化の一部について説明します。

ヒント NGEN.exe ユーティリティを使用して、インストール時にアプリケーションをコンパイルすることで、クライアント アプリケーションの起動時間を短縮します。

メソッドのインライン化

メソッド呼び出しにはコストがかかります。引数をスタックにプッシュするか、レジスタに格納する必要があります。メソッドプロローグとエピローグを実行する必要があります。 呼び出されるメソッドのメソッド本体を呼び出し元の本体に移動するだけで、特定のメソッドでこれらの呼び出しのコストを回避できます。 これは、メソッドの内張りと呼ばれます。 JIT では、いくつかのヒューリスティックを使用して、メソッドをインライン化する必要があるかどうかを決定します。 以下は、それらのより重要なリストです(これは網羅的ではないことに注意してください)。

  • IL の 32 バイトを超えるメソッドはインライン化されません。
  • 仮想関数はインライン化されません。
  • 複雑なフロー制御を持つメソッドは、並べられません。 複雑なフロー制御は、この場合以外 if/then/else; の任意のフロー制御または switchwhileです。
  • 例外処理ブロックを含むメソッドはインライン化されませんが、例外をスローするメソッドは引き続きインライン化の対象となります。
  • メソッドの仮引数のいずれかが構造体である場合、メソッドはインライン化されません。

これらのヒューリスティックは、今後のバージョンの JIT で変更される可能性があるため、明示的にコーディングすることを慎重に検討します。 メソッドの正確性を損なって、インライン化されることを保証しないでください。 興味深いことに、 inline C++ の キーワードと __inline キーワードでは、コンパイラがメソッドをインライン化する保証はありません (ただし __forceinline )。

プロパティの get メソッドと set メソッドは、通常、プライベート データ メンバーを初期化することなので、通常、インライン化の候補として適しています。

**HINT **インライン化を保証しようとして、メソッドの正確性を損なわない。

範囲チェックの削除

マネージド コードの多くの利点の 1 つは、自動範囲チェックです。array[index] セマンティクスを使用して配列にアクセスするたびに、JIT によってチェックが出力され、インデックスが配列の境界内にあることを確認します。 反復回数が多く、反復ごとに実行される命令の数が少ないループのコンテキストでは、これらの範囲チェックはコストがかかる可能性があります。 JIT では、これらの範囲チェックが不要であることを検出し、ループの本体からチェックを排除し、ループ実行が開始される前に 1 回だけチェックする場合があります。 C# では、"for" ステートメントで配列の長さを明示的にテストするという、これらの範囲チェックが排除されることを確認するプログラムパターンがあります。 このパターンからの微妙な逸脱により、チェックが削除されず、この場合はインデックスに値が追加されることに注意してください。

C での範囲チェックの削除#

//Range check will be eliminated
for(int i = 0; i < myArray.Length; i++) 
{
   Console.WriteLine(myArray[i].ToString());
}

//Range check will NOT be eliminated
for(int i = 0; i < myArray.Length + y; i++) 
{ 
   Console.WriteLine(myArray[i+x].ToString());
}

最適化は、たとえば、内部ループと外側ループの範囲チェックの両方が排除されるため、大きなジャグ配列を検索する場合に特に顕著です。

変数の使用状況の追跡を必要とする最適化

多くの JIT コンパイラ最適化では、JIT で仮引数とローカル変数の使用を追跡する必要があります。たとえば、 が最初にいつ使用され、 メソッドの本体で最後に使用されたときなどです。 CLR のバージョン 1.0 と 1.1 では、JIT が使用状況を追跡する変数の合計数に対して 64 という制限があります。 使用状況の追跡を必要とする最適化の例として、Enregistration があります。 Enregistration は、スタック フレーム (RAM など) ではなく、プロセッサ レジスタに変数が格納される場合です。 Enregistered 変数へのアクセスは、フレーム上の変数がプロセッサ キャッシュ内にある場合でも、スタック フレーム上にある場合よりも大幅に高速です。 Enregistration では、64 個の変数のみが考慮されます。他のすべての変数がスタックにプッシュされます。 使用状況の追跡に依存する Enregistration 以外の最適化もあります。 JIT 最適化の最大数を確保するには、メソッドの仮引数とローカルの数を 64 未満に保つ必要があります。 CLR の将来のバージョンでは、この数が変更される可能性があることに注意してください。

ヒント メソッドを短くします。 これには、メソッドのインライン化、登録、JIT 期間など、さまざまな理由があります。

その他の JIT 最適化

JIT コンパイラでは、定数とコピーの伝達、ループの不変ホイストなど、さまざまな最適化が行われます。 これらの最適化を取得するために使用する必要がある明示的なプログラミング パターンはありません。これらは無料です。

Visual Studio でこれらの最適化が表示されないのはなぜですか?

[デバッグ] メニューから [開始] を使用するか、F5 キーを押して Visual Studio でアプリケーションを起動すると、リリース バージョンとデバッグ バージョンのどちらがビルドされているかに関係なく、すべての JIT 最適化が無効になります。 マネージド アプリケーションがデバッガーによって起動されると、アプリケーションのデバッグ ビルドではない場合でも、JIT は最適化されていない x86 命令を出力します。 JIT で最適化されたコードを出力する場合は、Windows エクスプローラーからアプリケーションを起動するか、Visual Studio 内から Ctrl + F5 キーを使用します。 最適化された逆アセンブリを表示し、最適化されていないコードと比較する場合は、cordbg.exeを使用できます。

ヒント cordbg.exeを使用して、JIT によって生成された最適化されたコードと最適化されていないコードの両方の逆アセンブリを確認します。 cordbg.exeを使用してアプリケーションを起動した後、次のように入力して JIT モードを設定できます。

(cordbg) mode JitOptimizations 1
JIT's will produce optimized code

(cordbg) mode JitOptimizations 0

JIT では、デバッグ可能な (最適化されていない) コードが生成されます。

値型

CLR は、参照型と値型の 2 つの異なるセットを公開します。 参照型は常にマネージド ヒープに割り当てられ、(名前が示すように) 参照渡しされます。 値型は、ヒープ上のオブジェクトの一部としてスタックまたはインラインに割り当てられ、既定で値渡しされますが、参照渡しすることもできます。 値型は割り当てに非常に安価であり、小さくシンプルに保たれていると仮定すると、引数として渡すのが安いです。 値型を適切に使用する良い例として、 x 座標と y 座標を含む Point 値型があります。

ポイント値の種類

struct Point
{
   public int x;
   public int y;
   
   //
}

値型はオブジェクトとして扱うこともできます。たとえば、オブジェクト メソッドを呼び出したり、オブジェクトにキャストしたり、オブジェクトが必要な場所で渡したりすることができます。 この場合、値型は Boxing というプロセスを通じて参照型に変換されます。 値の型が Boxed の場合、マネージド ヒープに新しいオブジェクトが割り当てられ、その値が新しいオブジェクトにコピーされます。 これはコストのかかる操作であり、値型を使用して得られるパフォーマンスを低下させたり、完全に否定したりすることができます。 Boxed 型が暗黙的または明示的に値型にキャストバックされると、ボックス化解除されます。

Box/Unbox Value Type

C#:

int BoxUnboxValueType()
{
   int i = 10;
   object o = (object)i; //i is Boxed
   return (int)o + 3; //i is Unboxed
}

Msil:

.method private hidebysig instance int32
        BoxUnboxValueType() cil managed
{
  // Code size       20 (0x14)
  .maxstack  2
  .locals init (int32 V_0,
           object V_1)
  IL_0000:  ldc.i4.s   10
  IL_0002:  stloc.0
  IL_0003:  ldloc.0
  IL_0004:  box        [mscorlib]System.Int32
  IL_0009:  stloc.1
  IL_000a:  ldloc.1
  IL_000b:  unbox      [mscorlib]System.Int32
  IL_0010:  ldind.i4
  IL_0011:  ldc.i4.3
  IL_0012:  add
  IL_0013:  ret
} // end of method Class1::BoxUnboxValueType

カスタム値型 (C# の構造体) を実装する場合は、 ToString メソッドのオーバーライドを検討する必要があります。 このメソッドをオーバーライドしない場合、値型で ToString を呼び出すと、型が Boxed になります。 これは 、System.Object から継承される他のメソッド (その場合は Equals) にも当てはまりますが、 ToString はおそらく最も頻繁に呼び出されるメソッドです。 値の型がボックス化されているかどうかを知りたい場合は、(上記のスニペットのように) ildasm.exe ユーティリティを使用して MSIL で命令を探 box すことができます。

ボックス化を防ぐために C# で ToString() メソッドをオーバーライドする

struct Point
{
   public int x;
   public int y;

   //This will prevent type being boxed when ToString is called
   public override string ToString()
   {
      return x.ToString() + "," + y.ToString();
   }
}

コレクション (たとえば、float の ArrayList) を作成する場合、コレクションに追加されると、すべての項目がボックス化されることに注意してください。 配列を使用するか、値型のカスタム コレクション クラスを作成することを検討する必要があります。

C でコレクション クラスを使用する場合の暗黙的なボックス化#

ArrayList al = new ArrayList();
al.Add(42.0F); //Implicitly Boxed becuase Add() takes object
float f = (float)al[0]; //Unboxed

例外処理

通常のフロー制御としてエラー条件を使用するのが一般的です。 この場合、Active Directory インスタンスにユーザーをプログラムで追加しようとすると、ユーザーの追加を試みることができます。E_ADS_OBJECT_EXISTS HRESULT が返された場合は、ディレクトリに既に存在していることがわかります。 または、ユーザーのディレクトリを検索し、検索が失敗した場合にのみユーザーを追加することもできます。

通常のフロー制御にエラーを使用することは、CLR のコンテキストでのパフォーマンスの反パターンです。 CLR でのエラー処理は、構造化例外処理で行われます。 マネージド例外は、スローするまで非常に安価です。 CLR では、例外がスローされると、スローされた例外の適切な例外ハンドラーを見つけるためにスタック ウォークが必要です。 スタックウォーキングは高価な操作です。 例外は、その名前が示すように使用する必要があります。例外的、または予期しない状況では。

**HINT **パフォーマンスクリティカルなメソッドに対して、例外をスローするのではなく、予想される結果の列挙結果を返す場合を検討してください。

**HINT **アプリケーションでスローされる例外の数を示す .NET CLR 例外パフォーマンス カウンターがいくつかあります。

**HINT **を使用している場合 VB.NET ではなく On Error Goto例外を使用します。エラー オブジェクトは不要なコストです。

スレッド処理と同期

CLR では、独自のスレッド、スレッド プール、さまざまな同期プリミティブを作成する機能など、豊富なスレッド処理と同期機能が公開されています。 CLR でのスレッド処理のサポートを利用する前に、スレッドの使用を慎重に検討する必要があります。 スレッドを追加すると、実際にはスループットが低下し、メモリ使用率が増加することを確認できることに注意してください。 マルチプロセッサ コンピューターで実行されるサーバー アプリケーションでは、スレッドを追加すると、実行を並列化することでスループットが大幅に向上します (ただし、実行のシリアル化など、ロック競合が発生している量によって異なります)。また、クライアント アプリケーションでは、アクティビティや進行状況を表示するスレッドを追加すると、認識されるパフォーマンスが向上する可能性があります (スループット コストは小さくなります)。

アプリケーション内のスレッドが特定のタスクに特化されていない場合、または特別な状態が関連付けられている場合は、スレッド プールの使用を検討する必要があります。 過去に Win32 スレッド プールを使用したことがある場合、CLR のスレッド プールは非常によく知られています。 マネージド プロセスごとにスレッド プールのインスタンスが 1 つあります。 スレッド プールは、作成するスレッドの数に関してスマートであり、マシンの負荷に応じてそれ自体を調整します。

スレッド処理は、同期について説明しないと説明できません。マルチスレッドによってアプリケーションに与えられるすべてのスループットの向上は、誤って書き込まれた同期ロジックによって否定される可能性があります。 ロックの細分性は、ロックの作成と管理のコストと、ロックが実行をシリアル化する可能性があるという事実の両方により、アプリケーションの全体的なスループットに大きな影響を与える可能性があります。 この点を説明するために、ツリーにノードを追加しようとする例を使用します。 たとえば、ツリーが共有データ構造になる場合、アプリケーションの実行中に複数のスレッドがアクセスする必要があり、ツリーへのアクセスを同期する必要があります。 ノードの追加中にツリー全体をロックすることもできます。つまり、1 つのロックを作成するコストのみが発生しますが、ツリーにアクセスしようとする他のスレッドはブロックされる可能性があります。 これは、粒度の粗いロックの例です。 または、ツリーを走査するときに各ノードをロックすることもできます。つまり、ノードごとにロックを作成するコストが発生しますが、ロックした特定のノードにアクセスしようとしない限り、他のスレッドはブロックされません。 これは、きめ細かいロックの例です。 おそらく、より適切な粒度のロックは、操作しているサブツリーのみをロックすることでしょう。 この例では、複数のリーダーが同時にアクセスできる必要があるため、共有ロック (RWLock) を使用する可能性があることに注意してください。

同期された操作を実行する最も簡単で最高のパフォーマンスの方法は、System.Threading.Interlocked クラスを使用することです。 Interlocked クラスは、いくつかの低レベルのアトミック操作 ( IncrementDecrementExchangeCompareExchange) を公開します。

C での System.Threading.Interlocked クラスの使用#

using System.Threading;
//...
public class MyClass
{
   void MyClass() //Constructor
   {
      //Increment a global instance counter atomically
      Interlocked.Increment(ref MyClassInstanceCounter);
   }

   ~MyClass() //Finalizer
   {
      //Decrement a global instance counter atomically
      Interlocked.Decrement(ref MyClassInstanceCounter);
      //... 
   }
   //...
}

おそらく、最も一般的に使用される同期メカニズムは、モニターまたはクリティカル セクションです。 モニター ロックは、直接使用することも、C# の キーワード (keyword)をlock使用して使用することもできます。 キーワード (keyword)はlock、特定のオブジェクトのアクセスを特定のコード ブロックに同期します。 かなり軽く争われるモニター ロックは、パフォーマンスの観点からは比較的安価ですが、高い競争が行われるとコストが高くなります。

C# ロック キーワード (keyword)

//Thread will attempt to obtain the lock
//and block until it does
lock(mySharedObject)
{
   //A thread will only be able to execute the code
   //within this block if it holds the lock
}//Thread releases the lock

RWLock は共有ロック メカニズムを提供します。たとえば、"リーダー" はロックを他の "リーダー" と共有できますが、"ライター" は共有できません。 これが該当する場合、RWLock はモニターを使用するよりもスループットが向上する可能性があります。これにより、一度に 1 つのリーダーまたはライターのみがロックを取得できます。 System.Threading 名前空間には、Mutex クラスも含まれています。 ミューテックスは、プロセス間同期を可能にする同期プリミティブです。 これは重要なセクションよりも大幅にコストが高く、プロセス間同期が必要な場合にのみ使用する必要があることに注意してください。

リフレクション

リフレクションは CLR によって提供されるメカニズムであり、実行時にプログラムで型情報を取得できます。 リフレクションは、マネージド アセンブリに埋め込まれているメタデータに大きく依存します。 多くのリフレクション API では、メタデータの検索と解析が必要です。これはコストの高い操作です。

リフレクション API は、3 つのパフォーマンス バケットにグループ化できます。型比較、メンバー列挙、およびメンバー呼び出し。 これらのバケットはそれぞれ、徐々にコストが高くなります。 型比較操作 (この場合、C# の typeofGetTypeisIsInstanceOfType など) は、リフレクション API の中で最も安価ですが、決して安価ではありません。 メンバー列挙を使用すると、クラスのメソッド、プロパティ、フィールド、イベント、コンストラクターなどをプログラムで調べることができます。 これらの使用例は、デザイン時のシナリオで、この場合は Visual Studio のプロパティ ブラウザーの Customs Web コントロールのプロパティを列挙します。 リフレクション API の中で最もコストの高いものは、クラスのメンバーを動的に呼び出したり、JIT を動的に生成してメソッドを実行したりできる API です。 アセンブリの動的読み込み、型のインスタンス化、メソッド呼び出しが必要な遅延バインディングシナリオは確かにありますが、この疎結合には明示的なパフォーマンスのトレードオフが必要です。 一般に、リフレクション API は、パフォーマンスに依存するコード パスでは避ける必要があります。 リフレクションは直接使用しませんが、使用する API でそれを使用する可能性があることに注意してください。 そのため、リフレクション API の推移的な使用にも注意してください。

遅延バインディング

遅延バインディング呼び出しは、内部でリフレクションを使用する機能の例です。 Visual Basic.NET と JScript.NET の両方で、遅延バインディング呼び出しがサポートされています。 たとえば、変数を使用する前に宣言する必要はありません。 遅延バインディング オブジェクトは実際には オブジェクト型であり、Reflection は実行時にオブジェクトを正しい型に変換するために使用されます。 遅延バインディング呼び出しは、直接呼び出しよりも桁違いに遅くなります。 遅延バインディング動作が特に必要な場合を除き、パフォーマンスクリティカルなコード パスでの使用は避ける必要があります。

ヒントVB.NET を使用していて、明示的に遅延バインディングが必要ない場合は、 と Option Strict On をソース ファイルの先頭に含Option Explicit Onめることで、コンパイラに許可しないように指示できます。 これらのオプションを使用すると、変数を強制的に宣言して厳密に入力し、暗黙的なキャストをオフにします。

セキュリティ

セキュリティは CLR の必須かつ不可欠な部分であり、それに関連するパフォーマンス コストがあります。 コードが完全に信頼されていて、セキュリティ ポリシーが既定の場合、セキュリティはアプリケーションのスループットと起動時間に小さな影響を与える必要があります。 部分的に信頼されたコード (インターネットゾーンやイントラネット ゾーンのコードなど) や MyComputer Grant Set を絞り込むと、セキュリティのパフォーマンス コストが増加します。

COM 相互運用とプラットフォーム呼び出し

COM 相互運用機能とプラットフォーム呼び出しは、ネイティブ API をマネージド コードにほぼ透過的な方法で公開します。ほとんどのネイティブ API を呼び出す場合、通常は特別なコードは必要ありませんが、マウスを数回クリックする必要がある場合があります。 予想通り、マネージド コードからネイティブ コードを呼び出すことに関連するコストが発生し、その逆も発生します。 このコストには 2 つのコンポーネントがあります。ネイティブ コードとマネージド コード間の切り替えを行うことに関連する固定コストと、必要になる可能性がある引数と戻り値のマーシャリングに関連する変動コストです。 COM 相互運用機能と P/Invoke の両方のコストに対する固定の貢献度は小さく、通常は 50 未満の命令です。 マネージド型との間のマーシャリングのコストは、境界の両側での表現の違いによって異なります。 大量の変換を必要とする型の方がコストが高くなります。 たとえば、CLR 内のすべての文字列は Unicode 文字列です。 ANSI 文字配列を必要とする P/Invoke を介して Win32 API を呼び出す場合は、文字列内のすべての文字を絞り込む必要があります。 ただし、ネイティブ整数配列が必要な場合にマネージド整数配列を渡す場合は、マーシャリングは必要ありません。

ネイティブ コードの呼び出しに関連するパフォーマンス コストがあるため、コストが正当であることを確認する必要があります。 ネイティブ呼び出しを行う場合は、ネイティブ呼び出しで行われる作業によって、呼び出しの実行に関連するパフォーマンス コストが正当化されることを確認します。メソッドは "おしゃべり" ではなく "チャンキー" に保ちます。ネイティブ呼び出しのコストを測定する良い方法は、引数を受け取る必要がなく、戻り値がないネイティブ メソッドのパフォーマンスを測定し、呼び出すネイティブ メソッドのパフォーマンスを測定することです。 この違いにより、マーシャリング コストが示されます。

ヒント "Chatty" 呼び出しとは対照的に、"Chunky" COM 相互運用と P/Invoke 呼び出しを行い、呼び出しのコストが呼び出しの作業量によって正当化されるようにします。

マネージド スレッドに関連付けられたスレッド モデルがないことに注意してください。 COM 相互運用呼び出しを行う場合は、呼び出しが行われるスレッドが正しい COM スレッド モデルに初期化されていることを確認する必要があります。 これは通常、MTAThreadAttribute と STAThreadAttribute を使用して行われます (ただし、プログラムで行うこともできます)。

パフォーマンス カウンター

.NET CLR には、いくつかの Windows パフォーマンス カウンターが公開されています。 これらのパフォーマンス カウンターは、最初にパフォーマンスの問題を診断するとき、またはマネージド アプリケーションのパフォーマンス特性を特定しようとするときに、開発者が選択する武器である必要があります。 メモリ管理と例外に関連するいくつかのカウンターについて既に説明しました。 CLR と.NET Frameworkのほぼすべての側面に対するパフォーマンス カウンターがあります。 これらのパフォーマンス カウンターは常に使用可能であり、非侵襲的です。オーバーヘッドが少なく、アプリケーションのパフォーマンス特性は変更されません。

その他のツール

パフォーマンス カウンターと CLR プロファイラー以外に、従来のプロファイラーを使用して、アプリケーションのどのメソッドが最も時間がかかり、最も頻繁に呼び出されているかを確認する必要があります。 これらは、最初に最適化するメソッドになります。 Compuware の DevPartner Studio Professional Edition 7.0 や Intel® の VTune™ パフォーマンス アナライザー 7.0 など、マネージド コードをサポートする多くの商用プロファイラーを使用できます。 Compuware では、DevPartner Profiler Community Edition と呼ばれるマネージド コード用の無料プロファイラーも生成されます。

まとめ

この記事では、パフォーマンスの観点から CLR と.NET Frameworkの調査を開始します。 CLR のアーキテクチャと、アプリケーションのパフォーマンスに影響を与える.NET Frameworkには、他にも多くの側面があります。 私が開発者に与えることができる最善のガイダンスは、アプリケーションがターゲットとするプラットフォームと使用している API のパフォーマンスに関する仮定を一切行わないということです。 すべてを測定!

幸せなジャグリング。

リソース