スレッド パフォーマンス
Visual Studio 2010 におけるリソース競合の同時実行プロファイリング
Maxim Goldin
マルチコア プロセッサがかつてないほど一般的になるにつれ、ソフトウェア開発者は、パフォーマンスを向上するために、プロセッサの追加処理能力を活用するマルチスレッド アプリケーションを構築するようになっています。並列スレッドの能力を活用すると、作業全体を個別のタスクに分離でき、これらのタスクを並列実行できます。
ただし、多くの場合、タスクを完了するためにはスレッドが相互に通信する必要があります。また、場合によっては、アルゴリズムやデータ アクセスで必要であれば、スレッドの動作を同期することも必要です。たとえば、同じデータに同時に行われる書き込みアクセスは、データの破損を避けるために、相互に排他的な方法でスレッドに許可される必要があります。
同期は、多くの場合、共有同期オブジェクトを使用することによって実現されます。この場合、共有同期オブジェクトを取得するスレッドに、重要なコードやデータへの共有アクセスまたは排他アクセスが許可されます。アクセスの必要がなくなると、スレッドは所有権を放棄して、他のスレッドがアクセスを試みることが可能になります。使用される同期の種類によっては、所有権を同時に要求すると、複数のスレッドが共有リソースに同時にアクセスできる場合も、オブジェクトが以前取得されていたスレッドから解放されるまでいくつかのスレッドがブロックされる場合もあります。例としては、EnterCriticalSection アクセス ルーチンと LeaveCriticalSection アクセス ルーチンを使用する C/C++ のクリティカル セクション、C/C++ の WaitForSingleObject 関数、および C# の lock ステートメントと Monitor クラスなどがあります。
スレッド間の同期を正しく行わないと、マルチスレッドの目的であるパフォーマンスの向上を実現できるどころか、パフォーマンスが低下してしまうため、同期メカニズムの選択には注意が必要です。このため、動作の停滞を引き起こすロックの競合によってスレッドがブロックされる状況を検出できることがいっそう重要になります。
Visual Studio 2010 のパフォーマンス ツールには、スレッド間における同時実行の競合を検出できる、リソース競合プロファイリングという新しいプロファイリング方法が用意されています。Wintellect の John Robbins のブログ記事 (wintellect.com/CS/blogs/jrobbins/archive/2009/10/19/vs-2010-beta-2-concurrency-resource-profiling-in-depth-first-look.aspx、英語) では、この機能の概要がわかりやすく解説されています。
今回の記事では、競合のプロファイリング調査の方法について順を追って説明し、Visual Studio 2010 IDE とコマンド ライン ツールの両方を使用して収集できるデータについて説明します。また、Visual Studio 2010 でデータを分析する方法について紹介するだけでなく、競合の調査中に 1 つの分析ビューから別の分析ビューに移動する方法を示します。続いて、コードを修正し、修正したアプリケーションのプロファイリング結果を元のプロファイリング結果と比較して、修正によって競合の数が減ることを検証します。
まずは問題から
例として、Hazim Shafi がブログ記事「Performance Pattern 1: Identifying Lock Contention (パフォーマンス パターン 1: ロックの競合を特定する)」(blogs.msdn.com/hshafi/archive/2009/06/19/performance-pattern-1-identifying-lock-contention.aspx、英語) で使用したのと同じ、行列乗算アプリケーションを使用します。コード例は C++ で記述していますが、ここで解説する概念はマネージ コードに等しく適用できます。
行列乗算アプリケーションの例では、2 つの行列を乗算するために複数のスレッドを使用します。各スレッドは、ジョブの一部を取得して、次のコード スニペットを実行します。
for (i = myid*PerProcessorChunk;
i < (myid+1)*PerProcessorChunk;
i++) {
EnterCriticalSection(&mmlock);
for (j=0; j<SIZE; j++) {
for (k=0; k<SIZE; k++) {
C[i][j] += A[i][k]*B[k][j];
}
}
LeaveCriticalSection(&mmlock);
}
各スレッドには固有の ID (myid) があり、入力として行列 A および B を使用して、結果の行列 C に (1 行以上の) 行数を計算する役割があります。コードを詳しく見てみると、本当の意味であいまいな書き込み共有は発生せず、各スレッドはそれぞれ別の C 行に書き込むのがわかります。しかし開発者は、クリティカル セクションのある行列への割り当てを保護することを決めました。このおかげで、冗長な同期を簡単に発見できる Visual Studio 2010 の新しいパフォーマンス ツールを紹介できます。
プロファイリング データの収集
先ほど示したコードを含む Visual Studio プロジェクトがあるとします (必須ではありません。既に実行しているアプリケーションにプロファイラーをアタッチしてもかまいません)。[分析] メニューの [パフォーマンス ウィザードの起動] をクリックして、競合のプロファイリングを開始します。
ウィザードの最初のページで、図 1 のように [同時実行] を選択して、[リソース競合データを収集] チェック ボックスがオンになっていることを確認します。リソース競合の同時実行プロファイリングは、どのバージョンの Windows OS でも実行できますが、[マルチスレッド アプリケーションの動作を視覚化] オプションを使用するには、OS が Windows Vista か Windows 7 である必要があります。
図 1 同時実行リソースのプロファイリングの有効化
2 ページ目で、現在のプロジェクトが対象となっていることを確認します。最後のページで、[ウィザードの完了後にプロファイルを起動する] チェック ボックスがオンになっていることを確認して、[完了] をクリックします。これで、アプリケーションがプロファイラーの管理下で実行されます。アプリケーションが存在する場合、プロファイリング データ ファイルがパフォーマンス エクスプローラー ウィンドウに表示されます (図 2 参照)。
図 2 パフォーマンス エクスプローラーにおけるパフォーマンスのプロファイリング結果ファイル
プロファイリング レポートは自動的に Visual Studio で開き、概要ビューにパフォーマンスの調査結果が表示されます (図 3 参照)。
図 3 プロファイリング レポートの概要ビュー
プロファイリング データの分析
同期によって、必ずロックの競合が発生するわけではありません。ロック可能であれば、ロックの所有権を得ようとしてスレッドの実行がブロックされることはなく、競合も発生しません。リソース競合プロファイリング モードでは、プロファイラーは競合の原因となる同期イベントに限定してデータを収集し、正常な (ブロックされない) リソースの確保についてはレポートしません。アプリケーションに競合を発生させる原因がなければ、何のデータも収集されません。データを取得できたら、アプリケーションでロックの競合が発生していることになります。
競合ごとに、プロファイラーは、どのスレッドがブロックされたか、どこで競合が発生したか (リソースと呼び出し履歴)、いつ競合が発生したか (タイムスタンプ)、およびスレッドがロックを得ようとしたり、クリティカル セクションに入ろうとしたり、単一のオブジェクトを待機したりしようとしてブロックされている時間についてレポートします。
ファイルを開くと、まず概要ビューが表示されます。概要ビューには、簡単な診断に使用できる 3 つの領域があります (図 3 参照)。
- 競合のグラフでは、アプリケーションの有効期間における 1 秒ごとの競合の数がプロットされます。競合が急増していないかどうか視覚的に検証したり、時間間隔を選択したり、グラフを拡大したり、結果をフィルター処理したりすることができます。フィルター処理を行うと、選択した間隔のデータのみが再分析されます。
- "最も競合の多いリソース" テーブルでは、最も多く競合が検出されたリソースが一覧表示されます。
- "最も競合の多いスレッド" テーブルでは、競合の数が最も多かったスレッドが一覧表示されます。このテーブルでは、競合の長さではなく、競合の数が基準として使用されます。そのため、1 つの競合で長期間ブロックされているスレッドがあっても、概要ビューには表示されません。これに対して、ごく短期間スレッドをブロックする非常に短い競合でも、この数が多くあったスレッドは概要ビューに表示されます。
競合の大部分に関与しているリソースがあったら、そのリソースを詳しく調査します。予期しないほど多くの競合があるスレッドがあったら、そのスレッドの競合を調査します。
たとえば、図 3 では、Critical Section 1 がそのアプリケーションのほぼすべて (99.90%) の競合に関与しているのがわかります。そのリソースを詳細に調査してみましょう。
概要ビューのリソース名とスレッド ID はハイパーリンクになっています。Critical Section 1 をクリックすると、コンテキストが具体的なリソース (Critical Section 1) に設定された、リソースの詳細ビューが表示されます (図 4 参照)。
図 4 リソースの詳細ビュー
リソースの詳細
リソースの詳細ビューの上部には、時間に基づくグラフが表示されています。各横軸は、1 つのスレッドに属しています。コードでマネージ スレッドに名前を付けていない場合、軸はスレッドのルート関数名のラベルが付けられます (C# System.Threading.Thread.Name プロパティなど)。この軸の上にあるブロックは、リソースにおけるスレッドの競合を表します。ブロックの長さは、競合の長さです。時間が経つにつれて別の行のブロックと重なる場合がありますが、これはいくつかのスレッドが同時にそのリソースでブロックされたことを表します。
[合計] 行は特別です。特定のスレッドには属していませんが、このリソースにおけるすべてのスレッドの競合が含まれます (実際には、競合ブロックが軸に投影されたものです)。ご覧のとおり、Critical Section 1 は多忙です。[合計] 行にすきまが見当たりません。
マウスの左ボタンを使用して時間範囲を選択すると、グラフの特定の部分を拡大できます (グラフ内で開始位置を左クリックして、ポインターを右にドラッグします)。グラフの右上に、[ズームのリセット] と [縮小表示] という 2 つのリンクがあります。[ズームのリセット] をクリックすると、元のグラフ ビューに戻ります。[縮小表示] をクリックすると、拡大と同じように、グラフを少しずつ縮小できます。
競合ブロックの全体のパターンを見ることで、アプリケーションの実行についてなんらかの判断を下すことができます。たとえば、さまざまなスレッドの競合が、時間が経つにつれて頻繁に重なるようになったことから、最適な並列処理が行われているとはいえないのがわかります。また、各スレッドは、リソース上でブロックされている時間が実行されている時間よりも長く、これもアプリケーションの非効率性を表しています。
関数の詳細
リソースの詳細ビューの下部には、競合のコール スタックが表示されます。特定の競合を選択しない限り、何のデータも表示されません。ブロックを選択すると、それに対応するスタックが下のパネルに表示されます。グラフ上の競合ブロックにカーソルを合わせると、ポップアップ ウィンドウにスタックと競合の長さが表示されます。
競合のコール スタックをご覧いただくとわかるように、MatMult というサンプル アプリケーション関数の 1 つが表示されていて、それが競合の原因であることがわかります。その競合に関与している関数コードの行を特定するためには、[競合のコール スタック] パネルで、関数名をダブルクリックします。これにより、関数の詳細ビューが表示されます (図 5 参照)。
図 5 関数の詳細ビュー
このビューでは、MatMult を呼び出した関数と、その内部で呼び出された関数が視覚的に表示されます。ビューの下部のセクションでは、EnterCriticalSection(&mmlock) が、常時ブロックされているスレッドに関与していることが明確に示されます。
コードのどの行が競合に関与しているかわかったら、その方法で同期を実装するのを考え直すのではないでしょうか。たとえば、これはコードを保護する最善の策なのか、また、保護は本当に必要なのか考えてみましょう。
サンプル アプリケーションでは、同じ結果の行列への書き込みをスレッドが共有しないため、このコードでクリティカル セクションを使用する必要はありません。Visual Studio パフォーマンス ツールでは、mmlock の使用をコメント アウトできる箇所に移動します。これをコメントアウトすると、アプリケーションの処理速度が大幅に向上します。いつもこんなに簡単だといいですね。
関数の詳細ビューの詳細については、blogs.msdn.com/profiler/archive/2010/01/19/vs2010-investigating-a-sample-profiling-report-function-details.aspx (英語) で、Visual Studio Profiler チームのブログ記事を参照してください。
スレッドの詳細
先ほど説明したように、概要ビューは調査の開始位置として便利です。"最も競合の多いリソース" テーブルと "最も競合の多いスレッド" テーブルを参照すると、何を実行すべきかわかります。競合の多いスレッドの一覧の上位に想定していなかったスレッドが表示されていて、それを疑問に思ったら、詳細を確認できます。
概要ビューでスレッド ID をクリックして、スレッドの詳細ビューを表示します (図 6 参照)。このビューは、リソースの詳細ビューと似ていますが、今度は選択したスレッドのコンテキストで競合が表示されるため、リソースの詳細ビューとは目的が異なります。各横軸は、スレッドの有効期間中にスレッドが競合していたリソースを表します。このグラフでは、時間が経つにつれて重なっていく競合ブロックはありません。そうなると、同時に複数のリソースで同じスレッドがブロックされていたことになるためです。
図 6 競合ブロックが選択されているスレッドの詳細ビュー
(ここでは表示していない) WaitForMultipleObjects は別に処理されていて、一連のオブジェクトが単一のグラフ軸で表されています。これは、プロファイラーが WaitForMultipleObjects のあらゆるパラメーター オブジェクトを単一のエントリとして扱っているためです。
リソースの詳細ビューで実行できる操作 (グラフの拡大と縮小、特定の競合の選択とその長さのミリ秒単位での表示、およびスタックの呼び出し) はすべて、スレッドの詳細ビューでも同様に実行できます。[競合のコール スタック] パネルで関数名をダブルクリックすると、その関数の詳細ビューが表示されます。
例を参照すると、最初の部分では、スレッドは実行している時間よりもブロックされている時間の方が長く、その後、複数のハンドル (Multiple Handles) で長い時間ブロックされているのがわかります。最後のブロックは、他のスレッドが完了するのを待った結果なので、初期の競合から、スレッドの使用が最適ではなく、これによってスレッドが実行している状態よりもブロックされた状態に長く置かれているのがわかります。
問題の追跡
お気付きのように、グラフの軸ラベルはハイパーリンクになっています。これにより、リソースとスレッドの詳細ビューを切り替えることができ、その都度、必要なコンテキストがビューに設定されます。これは、問題の発見と解決のための反復アプローチに便利です。たとえば、多くのスレッドをブロックしているリソース R1 を調査するとします。まず、リソースの詳細ビューでスレッド T1 の詳細ビューを表示したところ、R1 だけでなく、ときどきリソース R2 でもスレッドがブロックされているのがわかりました。そこで R2 の詳細を表示して、R2 によってブロックされたすべてのスレッドを確認します。次に、スレッド T2 の名前をクリックすると、T2 をブロックしたすべてのリソースを確認できます。以下、同じように進めていきます。
競合プロファイリング データからは、そのときどきロックを保持しているスレッドははっきりとわかりません。ですが、スレッド間の同期オブジェクトが偏りなく使用されていて、なおかつアプリケーションの動作についての知識があれば、リソースの詳細ビューとスレッドの詳細ビューのデータを見比べることで、ロックの所有者 (同期ロックの取得に成功したスレッド) を推定できます。
たとえば、スレッドの詳細ビューで、リソース R において時間 t でブロックされているスレッド T を表示しているとします。R の名前をクリックすると、R のリソース詳細ビューに切り替えることができ、アプリケーションの有効期間中に R でブロックされたすべてのスレッドを確認できます。R において、時間 t では (T を含む) 多くのスレッドがブロックされています。R において時間 t でブロックされていないスレッドが、推定されるロックの保持者です。
グラフの [合計] 行は、すべての競合ブロックの投影だと説明しました。[合計] 行もハイパーリンクですが、リソースの詳細ビューでこのリンクをクリックすると、リソースごとの一連の競合コール ツリーである、競合ビューが表示されます (図 7 参照)。ここでは、該当するリソースのコール ツリーのホット パスがアクティブになっています。このビューでは、各リソースと、リソースのコール ツリー内の各ノード (関数) の、競合とブロック時間の統計が表示されます。他のビューと異なり、このビューでは、他のプロファイリング モードのようにリソースのコール ツリーに競合スタックを集計し、アプリケーションの実行全体の統計を出します。
図 7 ホット パスが Critical Section 1 に適用されている競合ビュー
競合ビューからは、コンテキスト メニューを使用して、任意のリソースの詳細ビューに戻ることができます。リソースをポイントして右クリックし、[競合リソースの詳細の表示] をクリックします。コンテキスト メニューからは、他にも便利な操作を実行できます。一般的には、プロファイラーのビューでのコンテキスト メニューの操作は、たいへん便利です。
スレッドの詳細ビューの [合計] をクリックすると、そのスレッドが選択されているプロセス ビューが表示されます (図 8 参照)。プロセス ビューでは、アプリケーションの開始時間を基準にしてスレッドが開始されたとき、スレッドが終了したとき、スレッドの実行時間、およびスレッドの競合の数が表示されます。また、すべての競合間でスレッドがブロックされた時間の長さがミリ秒単位と、スレッドの有効期間に対する割合として表示されます。
図 8 プロセス ビュー
ここでも、コンテキスト メニューから任意のスレッドの詳細ビューに戻ることが可能です。確認するスレッドを選択し、右クリックしたら [スレッド競合の詳細の表示] をクリックします。
また、他の調査フローとしては、ファイルを開いているときにプロセス ビューを直接表示して、いずれかの使用可能な列のタイトルをクリックしてスレッドを (競合の数などで) 並べ替え、任意のスレッドを選択し、コンテキスト メニューからスレッド競合の詳細グラフに切り替えることも可能です。
問題の解決と結果の比較
アプリケーションにおけるロックの競合の根本原因がわかったら、次のように mmlock のクリティカル セクションをコメント アウトして、もう一度プロファイリングを実行します。
for (i = myid*PerProcessorChunk;
i < (myid+1)*PerProcessorChunk;
i++) {
// EnterCriticalSection(&mmlock);
for (j=0; j<SIZE; j++) {
for (k=0; k<SIZE; k++) {
C[i][j] += A[i][k]*B[k][j];
}
}
// LeaveCriticalSection(&mmlock);
}
競合の数がどの程度少なくなるかと期待するでしょう。もちろん修正したコードのプロファイリングでは、ロックの競合は 1 つしかレポートされません (図 9 参照)。
図 9 修正したコードのプロファイリング結果の概要ビュー
Visual Studio では、新しいパフォーマンス結果と以前のパフォーマンス結果を比較することもできます。これには、パフォーマンス エクスプローラーで、比較するファイルを両方選択して (ファイルを 1 つ選択し、Shift キーか Ctrl キーを押して、もう 1 つのファイルを選択します) 右クリックし、[パフォーマンス レポートの比較] を選択します。
図 10 のような、比較レポートが表示されます。サンプル アプリケーションでは、MatMult 関数の包括的な競合の数が 1,003 から 0 に減ったのがわかります。
図 10 比較レポート
その他のデータ収集法
サンプリング、またはインストルメンテーション プロファイリング モードでパフォーマンス セッションを作成する場合は、後から同時実行モードに変換することが常に可能です。パフォーマンス エクスプローラーで、プロファイリング モードを変更するメニューを使用するとすばやく変換できます。目的に合ったモードを選択するだけでかまいません。
また、セッションのプロパティ設定を使用しても変換できます。パフォーマンス エクスプローラーでセッションをポイントし、右クリックしてコンテキスト メニューを表示して、[プロパティ] をクリックします。プロパティ ページの [全般] タブでは、プロファイリング セッションのモードと、その他のプロファイリングのパラメーターを制御できます。
プロファイリング モードを [同時実行] (ついでに言うと、[サンプリング] でもかまいません) に設定したら、アプリケーションを実行するか (パフォーマンス ウィザードから設定した場合は、[ターゲット] の一覧にあります。または、手動で一覧に追加することもできます)、実行しているアプリケーションにアタッチします。パフォーマンス エクスプローラーでは、図 11 のように、これらのタスクの実行を制御できます。
図 11 パフォーマンス エクスプローラーでのプロファイリングの制御
Visual Studio の UI では、プロファイリング データを収集するのに必要な多くの手順が自動化されます。ですが、コマンド ライン ツールを使用してプロファイリング データを収集することも可能です。これは、自動実行や自動スクリプトに便利です。
競合プロファイリング モードでアプリケーションを起動するには、Visual Studio のコマンド プロンプトを開きます (これにより、x86 ツールか x64 ツールのプロファイラー バイナリが使用するパスに配置されます) 。続いて、次の手順を実行します。
VSPerfCmd.exe /start:CONCURRENCY,RESOURCEONLY /output:<出力ファイル>
VSPerfCmd.exe /launch:<アプリケーション> /args:"<アプリケーションの引数>"
プロファイリングを実行します。
VSPerfCmd.exe /detach
- この手順は、アプリケーションが終了する場合必要ありませんが、害はないのでスクリプトに追加しても問題ありません。
VSPerfCmd.exe /shutdown
これで、Visual Studio で <出力ファイル>.VSP を開いて分析を行うことができます。
既に実行しているアプリケーションがあれば、次の手順を実行してプロファイラーをアタッチできます。
- VSPerfCmd.exe /start:CONCURRENCY,RESOURCEONLY /output:<出力ファイル>
- VSPerfCmd.exe /attach:<PID またはプロセス名>
- プロファイリングを実行します。
- VSPerfCmd.exe /detach
- VSPerfCmd.exe /shutdown
使用可能なコマンド ライン オプションの詳細については、msdn.microsoft.com/library/bb385768(VS.100) を参照してください。
Visual Studio に用意されているさまざまなビューを使用すると、収集したデータを詳細に調査できます。アプリケーションの有効期間全体を表示するビューもあれば、特定の競合のみを表示するビューもあります。最も役に立つものをお使いください。
プロファイリングの結果を分析するとき、ハイパーリンクやコンテキスト メニューを使用したり、ダブルクリックしたりして、各ビューの間を移動できます。または、ドロップダウン メニューから、使用可能なビューに直接切り替えることもできます。図 12 で、各ビューの概要を示します。
図 12 分析ビュー
ビュー | 説明 |
概要 | 調査の開始位置となるよう、概要情報が提供されます。これは最初に表示されるビューで、プロファイリング セッションが終了し、結果ファイルの準備が整った後に自動で開きます。 |
コール ツリー | すべての競合スタックが集計されたコール ツリーが表示されます。競合に関与しているスタックを特定できます。 |
モジュール | 競合を起こした各関数を含むモジュールの一覧が表示されます。各モジュールには、関連する関数の一覧と、検出された競合の数が表示されます。 |
呼び出し元/呼び出し先 | F 関数、F を呼び出すすべての関数、および F から呼び出される関数を表す 3 つのパネル ビューがあります (もちろん、呼び出しは競合をもたらすもののみです)。 |
関数 | 競合スタックにおいて検出されたすべての関数と、関連するデータの一覧が表示されます。 |
行 | ソース コード内の関数行が表示されます。 |
リソースの詳細 | 特定のリソース (ロックなど) の詳細が表示されるビューです。アプリケーションの有効期間中に、そのリソースにおいてブロックされたすべてのスレッドが表示されます。 |
スレッドの詳細 | 特定のスレッドの詳細が表示されるビューです。そのスレッドをブロックしたすべてのリソース (ロックなど) が表示されます。 |
競合 | コール ツリー ビューと似ていますが、ここではコール ツリーが各競合リソースごとに分けられます。つまり、このビューには一連のコール ツリーが表示され、どのコール ツリーにも、特定のリソースでブロックされたスタックが含まれています。 |
マーク | 自動記録および手動記録のマークの一覧が表示されます。各マークがタイムスタンプおよび Windows カウンターの値と関連付けられています。 |
プロセス | 調査されたプロセスの一覧が表示され、各プロセスにはそのスレッドの一覧が含まれています。また、各スレッドにおける、発生した競合の数と集計されたブロック時間が表示されます。 |
関数の詳細 | 特定の関数についての詳細を示すビューで、その関数によって呼び出された関数や、収集されたデータなどの情報が表示されます。 |
IP | 競合が発生した命令ポインターの一覧 (EnterCriticalSection、WaitForSingleObject などの関数の一覧です。これらが、競合が実際に発生する場所だからです) が表示されます。 |
Visual Studio の新しいリソース競合プロファイリング機能では、コード内のスレッド同期を使用して、パフォーマンスの問題を発見できます。また、不必要な同期を変更、削減、および削除して、アプリケーションの実行時間を快適なものにすることができます。
Maxim Goldin は、マイクロソフトのシニア ソフトウェア設計エンジニアです。2003 年から、Visual Studio Engineering チームで活動しています。連絡先は mgoldin@microsoft.com (英語のみ) で、ブログは blogs.msdn.com/b/mgoldin (英語) です。
パフォーマンスの調査フローの詳細については、この記事と同じことが解説されているブログ記事を blogs.msdn.com/b/mgoldin/archive/2010/04/22/resource-contention-concurrency-profiling-in-visual-studio-2010-performance-investigation-flows.aspx (英語) で参照してください。
この記事のレビューに協力してくれた技術スタッフの Steve Carroll、Anna Galaeva、Daryush Laqab、Marc Popkin-Paine、Chris Schmich、Colin Thomsen に心より感謝いたします。