アプリケーション実行の高速化
ハイパースレッディングのパワーでアプリケーションをスピード アップ
Yaniv Pessach
翻訳元: Juice Up Your App with the Power of Hyper-Threading (英語)
この記事で取り上げる話題:
- ハイパースレッディング対応 CPU の動作
- ハイパースレッディング対応コンピュータでのパフォーマンス
- アプリケーションのハイパースレッディング向け最適化
- CPU のキャッシュおよびアフィニティ
- ハイパースレッディングに適した同時実行のパターン
この記事で使用する技術: C#、.NET Framework、および Win32
目次
- ハイパースレッディングの重要性
- Windows から見た場合
- クライアント対サーバー
- ハイパースレッディングへの対応
- 複数 CPU によるパフォーマンス向上
- ヒットおよびミス
- ロック
- 偽共有とキャッシュのエイリアス
- 競合の発生
- サーバー アプリケーションとハイパースレッディング
- CPU アフィニティ
- Producer/Consumer
- ハイパースレッディングの検出
- まとめ
ハイパースレッディングは、プロセッサが 2 つの命令ストリームを同時実行できるようにすることで、CPU の効率を向上させる技術です。比較的最近の Intel Pentium 4 プロセッサに搭載されているこの機能により、アプリケーションのパフォーマンスは通常 20 ~ 30%、一部のアプリケーションでは最大 40% 向上します。
残念なことに、アプリケーションの中にはパフォーマンスがまったく向上しないものもあり、さらに一部のアプリケーション、特にこの記事で説明するような、パフォーマンスに関して推奨されるベスト プラクティスを遵守していないアプリケーションでは、パフォーマンスが大きく低下する可能性もあります (私は、パフォーマンスが 20% ほども低下したアプリケーションを見たことがあります)。さらに、この機能により単一プロセッサのコンピュータでも同時実行が可能になることで、マルチプロセッサのマシンでしか現れないマルチスレッディングのバグが、ハイパースレッディング対応の単一 CPU マシンでも現れる可能性があります。
この記事では、ハイパースレッディング技術について説明し、コードに並列性を追加することでハイパースレッディング対応マシンでのパフォーマンスを向上させる例を示します。また、ハイパースレッディング向けの高度な最適化についても説明し、いくつかの役立つパターンを示します。コードのサンプルは C# で書かれていますが、マネージ アプリケーションとアンマネージ アプリケーションのどちらにも、同じ原則が当てはまります。
ページのトップへ
1. ハイパースレッディングの重要性
ハイパースレッディング技術は、比較的最近の Pentium 4 CPU (多くの場合、クロック速度が 2.4GHz 以上のコンピュータに搭載されています)、およびクロック速度が 2.2 GHz 以上のすべての Xeon CPU で利用できます。ハイパースレッディング機能は、この機能を持つマシンのほとんど (すべてではありません) で、既定で有効化されています。
ハイパースレッディング以前の Pentium 4 では、一度に実行できる命令ストリームは 1 つだけでした。CPU は 1 つの命令ポインタ (IP) を保持しており、単一 CPU マシンでのすべてのマルチタスクは、CPU のタイムスライスをスレッドに割り当てるオペレーティング システムの機能に依存して実行されていました。ハイパースレッディングにより、Pentium 4 は 2 つの命令ストリームを同時に実行できるようになります。CPU は 2 つの命令ポインタとマシン状態を保持し、それら 2 つの実行を気づかれないように切り替えます。
プログラムからのマシン命令を実行するには、1 つ以上の実行スロットが必要になります。実行スロットは、浮動小数点ユニット (FPU) などの内部 CPU リソースに対応し、浮動小数点演算などの操作を実行する CPU の能力を表します。
Pentium 4 CPU では、どの実行スロットが使用可能であるかが常に認識されています。アウトオブオーダー実行 (CPU が、命令の実行を命令ストリームに出現した順序とは異なる順序で試行する) などの機能により、スロットが使用可能になるまで待機する (ストールと呼ばれます) のではなく、より多くの実行スロットが常時使用されるようにすることで、パフォーマンスが向上します。
2 つの実行ストリームを切り替える自由を CPU に与えることで、CPU の実行スロットの使用効率が上がります。1 つの命令ストリームが、メイン メモリへのアクセス時にストールしたり、使用中の実行スロットを要求した場合、CPU は、第 1 のストリームのためのメモリがフェッチされる間や、実行スロットが使用可能になるまでの間に、第 2 の命令ストリームの命令を実行することができます。
ハイパースレッディング対応 CPU では、2 つの命令ストリームが同時に実行されます。実際には、CPU は第 1 の命令ストリームの命令をいくつか実行し、次に第 2 の命令ストリームの命令をいくつか実行します。命令ストリームの切り替えは、実行スロットの使用可能性を最適化し、ストールの影響を最小化しながら、リソースを複数の実行ストリームに均等に配分するアルゴリズムに基づいて行われます。実行コンテキスト (たとえばレジスタ値) は、命令ストリームと共に保持されます。これらのさまざまな機能を持つことで、ハイパースレッディング対応 CPU は、2 つの物理 CPU をエミュレートしています。エミュレートされた各 CPU は、論理 CPU と呼ばれます。
ハイパースレッディングにより、CPU リソースの使用効率が向上し、それによってパフォーマンスが向上する可能性があります。その一方で、これらの CPU リソースは実際は共有されているため、2 つの命令ストリームが同じ共有リソースを要求した場合には、パフォーマンスが低下する可能性があります。
FPU や演算装置 (ALU) などの共有リソースだけでなく、最近アクセスされた RAM 領域をキャッシュする CPU キャッシュ (レベル 1 キャッシュとレベル 2 キャッシュに分かれており、それぞれ L1 および L2 と呼ばれます) 内のスペースも共有されています。L2 キャッシュ スペースは、論理 CPU には割り当てられません。キャッシュ スペースを共有することにより、パフォーマンスが向上する場合も、低下する場合もありますが、これについては後に説明します。
第 2 の実行ストリームが存在しない場合には、すべてのキャッシュ スペースおよびその他のリソースは、第 1 の実行ストリームが使用します。複数命令ストリーム モードはわずかにパフォーマンスを低下させるため、CPU は、命令が両方の論理 CPU で実行されるようにスケジュールされている場合にのみ、このモードを使用します。また、単一プロセッサのコンピュータでは、ハイパースレッディングを有効にするとマルチプロセッサ用のカーネルが使用されるため、同期はうまく処理できますが、パフォーマンスはわずかに低下します。
理想的な場合には、それぞれの命令ストリームは、CPU が命令ストリームを 1 つだけ実行している場合とほとんど同じくらい高速に実行されます。しかし実際には、2 つの命令ストリームが共有リソースへのアクセスを頻繁に要求せずに実行されることはほとんどないため、それぞれの命令ストリームの実行速度は、理想的な場合よりもかなり遅くなります。それでも、アプリケーションの動作を適切な仕方で 2 つに分割すれば、ハイパースレッディングによってアプリケーション全体のパフォーマンスが向上する可能性があります。
ページのトップへ
2. Windows から見た場合
Windows の最近のバージョンでは、各実行ストリームは論理 CPU として公開されるため、オペレーティング システムはスレッドの実行を各論理 CPU 上でスケジュールします。そのため、1 つの物理 CPU を備えたハイパースレッディング対応マシン、つまり 2 つの論理 CPU を備えたマシンでは、2 つのスレッドが実行される場合、それらのスレッドは別個の論理 CPU 上で同時に実行されます。
ユーザーには、マシン上に 2 つの論理 CPU が存在するものとして見え、2 つのスレッドが実行されている場合には、両方の論理 CPU 上で同時に実行されているように見えます。この記事では、特定の物理 CPU によって公開される第 1 の論理 CPU と第 2 の論理 CPU を、"logical1" および "logical2" と略記することにします。
Windows NT 以降のスケジューラは、ハイパースレッディングに対応しています。複数のスレッドが同時に実行される場合、それらのスレッドは、ハイパースレッディング非対応の単一 CPU の場合のように交替するタイムスライスを使用するのではなく、両方の論理 CPU 上でスケジュールされます。これにより、ハイパースレッディング対応システムでは、パフォーマンスと応答性が大幅に向上する可能性があります。
ハイパースレッディング対応のデュアル CPU の場合は、いっそう複雑になります。オペレーティング システムはスレッドを、まず最初にそれぞれの物理 CPU 上でスケジュールし、次に同じ物理 CPU 上の論理 CPU 上でスケジュールします。そのため、実行されているスレッドが 2 つだけの場合には、各スレッドは、同じ CPU 上でハイパースレッディングにより実行されるのではなく、それぞれ別個のプロセッサに割り当てられます。それはさておき、以下この記事では、ハイパースレッディングを有効化した単一 CPU マシンの場合について説明します。
ページのトップへ
3. クライアント対サーバー
アプリケーションをハイパースレッディング向けに最適化する際に重要な点は、両方の論理 CPU が常に有用な操作を実行するようにすることです。パフォーマンスをさらに向上させるために、スレッド間で負荷の配分を最適化する方法について説明します。
ここでの説明は、クライアント アプリケーションとサーバー アプリケーションの両方に関連します。サーバーの場合、負荷を単純に配分することが、最も有効であることがあります。たとえば、1 秒ごとに大量の要求が予期される ASP.NET アプリケーションや Web サービスを開発している場合、通常のマルチスレッド デザインを採用すれば、複数のスレッドが同時に実行されるようになります。そのため、既存のサーバー アプリケーションは、ハイパースレッディングを有効にすると、パフォーマンスがいっそう向上する傾向があります。
高速な I/O (ネットワークおよびディスク) アクセスを必要とするアプリケーションの場合は、操作を複数のスレッドに分割したり、非同期プログラミング モデルを使用したりすることで、良い結果が得られます。個々の要求が CPU に大きな負担をかけるクライアント アプリケーションやサーバー アプリケーションの場合、処理を複数のスレッドに分割することで、パフォーマンスがいっそう向上する可能性があります (これとは逆に、ハイパースレッディング非対応の単一 CPU コンピュータで実行するアプリケーションの場合、CPU に負担をかけるタスクを複数のスレッドに分割してもほとんど効果はなく、コンテキスト スイッチによるオーバーヘッドのために、かえってパフォーマンスが低下することもあります)。処理を UI スレッドとは別のスレッドで実行する (これは、使いやすさの点で、ほとんどの場合に良いアイディアです) クライアント アプリケーションの場合、ハイパースレッディング対応マシンでは、応答性がさらに向上します。
ページのトップへ
4. ハイパースレッディングへの対応
マルチスレッドのアプリケーションは、ハイパースレッディングを有効にすると、多くの場合、パフォーマンスや応答性が向上します。ここでは、パフォーマンスを最大化するために行うことのできる、いくつかのアプローチについて説明します。しかし、どのアプローチにもマイナス面があるため、アプローチが有効かどうかを評価する場合には、パフォーマンスを測定する必要があります。
作成したコードのパフォーマンスが向上する可能性を知るためには、まず最初に、ハイパースレッディング対応マシンと、ハイパースレッディング非対応マシンの両方で、実行時間を測定してみます (マシンの BIOS でハイパースレッディングを無効にできる場合には、同じマシンでテストすることもできます)。ハイパースレッディング向けにプログラミングすると、ほとんどの場合に、デュアルプロセッサ マシンでもアプリケーションのパフォーマンスが向上する、という別の利点もあります。ハイパースレッディングでのみ効果があり、デュアルプロセッサ マシンやマルチプロセッサ マシンでは効果のないアプローチについては、その点を明示しておきます。一般に、複数のプロセッサにスケーラブルに対応できるアプリケーションは、ハイパースレッディング環境でも優れたパフォーマンスを示します。
CPU に負担をかける操作を見つけるには、まず最初に、グラフィックスをレンダリングおよび描画したり、音楽や動画をエンコードまたはデコードしたり、データを圧縮または暗号化したり、緊密なループで実行したりしている部分をコードの中から探してみます。そのような CPU に負担をかける操作を識別するには、アプリケーションのプロファイルを書くことから始めます。
役立つツールとして、タスク マネージャ (TaskMgr.exe) があります (図 1 参照)。[パフォーマンス] タブに、各論理 CPU の使用率が個別に表示されます。CPU ごとに 1 つのグラフが表示されるように、タスク マネージャを構成しておく必要があります (それには、[表示] メニューの [列の選択] をポイントし、[CPU Usage] をクリックします)。
.gif)
図 1 デュアルプロセッサでハイパースレッディングを有効にした状態
すべての CPU、または個々の論理 CPU について、プロセッサの "% Processor Time" パフォーマンス カウンタをチェックしても、同様の情報を得ることができます。 PerfMon.exe は、この情報を取得するために役立つツールです。
CPU 使用率だけを測定しても、ホット スポットや、CPU のごく短時間での動作は表示されないため、CPU に負担をかける操作がすべて識別されるわけではない場合がありますが、CPU プロファイラは、このような対象を識別するために役立ちます (TaskMgr や PerfMon は、CPU 使用率を秒単位で平均するため、ごく短時間のホット スポットは識別できません)。CLR プロファイラは、VTune などのその他のプロファイラや、VisualStudio 2005 Team System に付属のプロファイリング ツールと同様に、CPU に大きな負担をかけるメソッドを識別するために役立ちます。
タスク マネージャと PerfMon のどちらの場合でも、CPU 使用率は誤解の元となる可能性があることに注意しておく必要があります。1 つのスレッドで実行されている CPU-bound な操作は、使用率が 50% と表示されます。しかし、論理 CPU 同士の間には、パフォーマンスに関して相互依存関係があるため、両方の CPU を使用しても、パフォーマンスが 2 倍になることはありません。
以下、この記事では、1 つの物理 CPU 上の 2 つの論理 CPU (LC1 および LC2) で、2 つのスレッド (T1 および T2) が実行されている場合を想定します。
ページのトップへ
5. 複数 CPU によるパフォーマンス向上
クライアント アプリケーションが、たとえばデータ セット全体にわたって複雑な計算を行う場合のような、CPU に負担をかける操作を実行しているとします。ユーザーが操作を開始すると、計算が 3 秒間実行された後、結果が出力されるとします。当然、待機時間はできる限り短くする必要があります。
まず最初に、アプリケーション実行中の CPU 使用率をチェックしますが、アプリケーションのどの部分が CPU を消費しており、最適化が必要であるかを識別するには、プロファイリング ツール (CLR プロファイラなど) が役立ちます。
ユーザーがハイパースレッディング対応マシンでアプリケーションを実行している場合、解決策の 1 つは、その処理を同時に実行される 2 つのスレッドに分割することです。これにより、両方の論理 CPU が使用されるようになります。この場合も、両方のスレッドが完了するまで待機する必要が生じます。このアプローチでは、各セグメントの処理に要する時間が同じであることが前提されていることに注意してください。この前提が満たされない場合、CPU のハイパースレッディング機能は、実行の後半部では十分に利用されないことになります。
より一般性のあるアプローチとしては、CPU に負担をかける操作を、実行の複数のタスクおよびスレッドに分割し、タスクとスレッドの数を論理プロセッサの数と同じになるようにします (I/O を含む操作には、より多くのスレッドが必要になります)。たとえば、ハイパースレッディングを有効にしたデュアルプロセッサのコンピュータでは、処理対象のデータ セットを 4 等分し、各部分を別個のスレッドで処理するようにします。各タスクを相互に独立に処理できる場合には、処理をこのように分割することで、ロックの必要性を減らすことができます。一般に、複数 CPU のマシンでも、ハイパースレッディング対応マシンでも、ロックの競合はパフォーマンスを大きく低下させるため、可能な限り避ける必要があります。
図 2 に、スレッディングを利用せずに、配列に関する数値処理を実行するコードのサンプルを示します。この負荷を分割する簡単な方法の 1 つは、図 3 のコード サンプルに示すように、配列を 2 等分し、そのそれぞれを .NET の ThreadPool で実行される別個のスレッドで処理することです。各スレッドは、処理が完了した時点で、それぞれの ManualResetEvent を通知します。一方、メイン スレッドは、両方のイベントが通知されるのを (WaitHandle.WaitAll 呼び出しで) 待機します。
図 2 1 スレッドによる、CPU に負担をかけるコード
public int[] records;
void DoWork(int firstElement, int lastElement, out int result)
{
result = 0;
for (int i= firstElement;i<=lastElement;i++)
{
int tempvaluei = records[i];
for (int j=0; j<1000; j++)
{
tempvaluei = (tempvaluei * 3 / 2) +
((tempvaluei * tempvaluei) % 70) + 1;
}
result += tempvaluei;
}
return result;
}
図 3 ThreadPool の使用
public class ThreadWorkItem
{
public int Result;
public int FirstElement;
public int LastElement;
public ManualResetEvent ManualEvent;
}
void DoWork2(int firstElement, int lastElement, ref int result)
{
ThreadWorkItem wi1 = new ThreadWorkItem();
wi1.FirstElement = firstElement;
wi1.LastElement = (firstElement + lastElement)/2;
wi1.ManualEvent = new ManualResetEvent(false);
ThreadWorkItem wi2 = new ThreadWorkItem();
wi2.FirstElement = wi1.LastElement+1;
wi2.LastElement = lastElement;
wi2.ManualEvent = new ManualResetEvent(false);
ThreadPool.QueueUserWorkItem(new WaitCallback(DoWorkCallBack), wi1);
ThreadPool.QueueUserWorkItem(new WaitCallback(DoWorkCallBack), wi2);
WaitHandle.WaitAll(new ManualResetEvent[]{
wi1.ManualEvent, wi2.ManualEvent});
result = wi1.Result + wi2.Result;
}
void DoWorkCallback(object state)
{
ThreadWorkItem wi = (ThreadWorkItem)state;
DoWork(wi.FirstElement, wi.LastElement, out wi.Result);
Wi.ManualEvent.Set();
}
DoWork2 メソッドは、計算のコストが、WorkItem の生成、イベントの待機、およびこれらのタスクを非同期で実行するために必要なその他すべての処理のオーバーヘッドよりも大きい場合にのみ高速化されます。
もちろん、他の方法で処理を分割することもできます。この例でいえば、各スレッドで半数の要素のみについてすべての計算を実行するのではなく、両方のスレッドをすべての要素について反復し、各スレッドで計算の一部だけを実行することもできます。どちらの方法が高速になるかは、多くの場合、CPU キャッシュの使用に依存します。
処理を 2 つのスレッドに分割すると、データ管理のオーバーヘッドや、スレッドの生成、管理、および切り替えに関連するオーバーヘッドが発生します。どのような操作でも、このアプローチで生じた実行 (およびコーディング) のオーバーヘッドが生じるというわけではありません。タスクを複数のスレッドに分割する場合は、事前にスレッドや同期のコストについて考慮しておく必要があります。
ページのトップへ
6. ヒットおよびミス
コードをハイパースレッディング向けに最適化する場合に重要な要素の 1 つは、共有 CPU キャッシュを最大限に利用することです。今日の CPU の速度に比べると、RAM は低速です。データ集約的な操作では、使用される CPU 時間の大部分を、メモリへのアクセスに要する時間が占める場合も多くあります。これは、ほとんどの CPU 命令 (たとえば整数の加算) が 1 サイクルで実行されるのに対し、メイン メモリへのアクセスには数百サイクルを要する場合があるためです。
これに対処するために、Pentium 4 には 2 レベルのメモリ キャッシュ システムが装備されています。L1 (レベル 1) データ キャッシュのサイズは通常は 8KB で、L2 キャッシュ (レベル 2) のサイズは 512KB ですが、キャッシュのサイズは CPU のリリースと共に増大し、最近の一部の CPU には、16KB の L1 と 2MB の L2 が搭載されています。プロセッサでメモリ内の情報が必要になったとき、そのデータがすでに L1 キャッシュにある場合 (L1 キャッシュ ヒットと呼ばれます) には、そのデータへのアクセスは 4 サイクル程度で実行できます。必要な情報が L2 キャッシュにある場合には、アクセスに 10 サイクル程度を要します。要求を RAM に対して行わなければならない場合、このキャッシュ ミスの結果として、100 サイクル以上を要する操作が必要になります。そして、その情報が物理メモリ内になく、ディスクから読み込む (ページングする) 必要が生じた場合には、待機時間は数百万 CPU サイクルにもなります。
Pentium 4 では、RAM のキャッシングは、キャッシュ ラインと呼ばれる単位で実行されます。ハイパースレッディングが有効なすべての CPU では、キャッシュ ラインのサイズは 64 バイトで、メモリの読み取り時には、最大で 2 つのキャッシュ ラインがフェッチされます。連続して参照されることの多い一連の項目をメモリ内で相互に近接した位置に配置する (一般に局所性と呼ばれます) と、後続のメモリ アクセスがキャッシュへのアクセスで済んでしまう可能性が増えるため、通常はパフォーマンスが向上します。
ご存じのとおり、参照の局所性は、共通言語ランタイム (CLR) によってすでに向上されています。オブジェクトを連続的に割り当てると、それらのオブジェクトはマネージ ヒープの連続したアドレスに割り当てられるため、それらのオブジェクトへの連続的なアクセスが高速化されます。ガベージ コレクションが実行される場合には、オブジェクトが相互に近接した位置に留まるように、ガベージ コレクタがヒープをコンパクトにします。(CLR は、ハイパースレッディングおよびキャッシュサイズにも対応することができ、それが実行されている特定のハードウェアを効率良く利用するために、追加の最適化を実行します。) データの局所性は、CPU が高速化するほど重要になります。一緒に使用されるデータは一緒に割り当てるのが良い習慣です。特に、それらのオブジェクトの有効期間が同じである場合はなおさらです。
これらのラインに加えて、ハイパースレッディング対応プロセッサで実行されるマルチスレッド アプリケーションのパフォーマンスは、複数のスレッドがメモリの共通の領域から読み取りを実行する場合にも向上します。1 つのスレッドが情報をメイン メモリから L2 キャッシュに読み込むと、第 2 のスレッドが後で同じ情報を要求した場合、この要求は L2 キャッシュによって満たされる可能性があります。貴重なキャッシュ スペースを節約するために、情報は L2 キャッシュに 2 度保存されることはないため (対称型マルチ物理プロセッサの場合、一般にキャッシュは CPU 間で共有されないため、通常このようなことは起こりません)、マルチプロセッサ マシンでは、両方のスレッドが同じ物理 CPU で実行されていることが確実である場合 (後に説明します) 以外は、その情報が L2 キャッシュに存在することを必ずしも期待することはできません。図 4 に、複数のスレッドで DoWork メソッドを実行している場合に、このようなキャッシュの効率の良さによってパフォーマンスが向上する例を示します。使用されている配列は、この例で言えば変換テーブルなどの、共通メモリ テーブルにすることができます。
図 4 読み取り専用データの共有
public int[] records = ...;
public int[] arr = ...;
void DoWork(int firstElement, int lastElement, out int result)
{
result = 0;
for (int i=firstElement; i<=lastElement; i++)
{
int tempvaluei = records[i];
for (int j=0; j<1000; j++)
{
tempvaluei = arr[(tempvaluei * 3 / 2 + 1) % arr.Length];
}
result += tempvaluei;
}
}
配列の要素は、他のスレッドが最近その要素にアクセスしている場合には、すでに CPU キャッシュに入れられている可能性が高くなります。しかし、簡単に計算できる値をテーブルにキャッシュすると (たとえば arr[i] = Math.Pow(r,i) という場合など)、値を単純に再計算する場合よりも遅くなる可能性があるため、いつもどおり必ずアプリケーションのプロファイルを作成してください。
スレッド間で書き込み可能なデータを共有することは、話が別です。情報の一貫性を維持するためには、共有データへのアクセスを、ロックで保護する必要があります。ロックを使用しないと、同期に関するバグが発生します。単一論理 CPU のマシンではまれにしか発現しない同期バグの多くが、ハイパースレッディング対応マシンや複数プロセッサのマシンでは、はるかに頻繁に発生する可能性があります。図 5 のコード サンプルを参照してください。このサンプルでは、2 つのスレッドが同時にカウンタを更新しています。
操作 ThreadWork.GlobalCounter++ は、GlobalCounter に格納されている値をフェッチし、それを 1 だけインクリメントし、元の変数に格納する、という 3 つの操作から構成されています。第 2 のスレッドが更新操作の途中で GlobalCounter をフェッチすると、古い値が取得されます。そのため、GlobalCounter が 1 回しかインクリメントされない、という事態が発生します。
単一プロセッサのコンピュータでは、スレッドは実際には同時に実行されるわけではありません。各スレッドはタイムスライスを取得し、ブロック操作 (たとえばファイル I/O) を発行するか、割り当てられたタイムスライスが終了するまで、実行が継続されます。そのため、更新操作の途中でスレッドが切り替えられることはあまりありません。ハイパースレッディング対応マシン (または複数の物理 CPU を持つマシン) では、操作は同時に実行されるため、操作途中にスレッドの切り替えが発生する可能性が高くなります。たとえば、図 5 のコードは、私が所有するハイパースレッディング非対応のマシンでは正しい結果を出力しましたが、ハイパースレッディング対応マシンでは、20% も少ない値を出力しました。これは良くないことです。
ハイパースレッディングやマルチプロセッサで発生する、検出しにくいバグのもう 1 つの発生源としては、スレッドの優先順位に依存したロッキングがあります。優先順位の異なる複数のスレッドを使用するプログラムの中には、優先順位の高いスレッドの実行中には優先順位の低いスレッドは実行されていない、ということを前提しているものがあります。このような想定は、優先順位の高いスレッドがブロックしている可能性がある (I/O の実行中に発生したり、Windows のスケジューラに特有の飢餓状態防止スケジューリングで定期的に発生したりします) ため、単一 CPU システムでも危険ですが、ハイパースレッディングのシステムでは、CPU は実行可能なスレッドを、1 つだけではなく、上位のものから 2 つ実行します (デュアルプロセッサ システムでは、上位 2 つのスレッドは、それぞれ別個の CPU で実行されます)。また、ハイパースレッディングのシステムでは、優先順位の低いスレッドが CPU リソースをめぐって優先順位の高いスレッドと競合する (両方のスレッドとも別個の命令ストリームとして実行されますが、CPU はスレッドの優先順位を認識しないため) ことで、ポーリング、低位値の操作、または "ビジーによる待機" を実行しているバックグラウンド スレッドのパフォーマンスが低下する可能性があります。
ページのトップへ
7. ロック
ハイパースレッディング対応マシンまたは複数 CPU のマシンで実行するコードを作成する場合は、ロックの実装方法に特に注意してください。図 5 のコードを修正する簡単な方法の 1 つとして、ThreadWork.globalCounter++ を使用する代わりに、Interlocked.Increment(ref ThreadWork.globalCounter) を使用します。より複雑な構造の場合には、Monitor、Mutex、および Semaphore などの、より複雑な同期プリミティブを使用する必要があります。
public class ThreadWork
{
public static int GlobalCounter;
private ManualResetEvent manualEvent;
private int countTo;
public ThreadWork(ManualResetEvent manualEvent, int countTo)
{
this.manualEvent = manualEvent;
this.countTo = countTo;
}
public void DoWork()
{
for(int i=0; i<countTo; i++)
{
ThreadWork.GlobalCounter++;
}
manualEvent.Set();
}
}
class TestCounters
{
public static void TestCounter()
{
int numThreads = 10;
int countTo = 100000;
ManualResetEvent[] events = new ManualResetEvent[numThreads];
Thread[] threads = new Thread[numThreads];
ThreadWork.GlobalCounter = 0;
for (int j=0; j<numThreads; j++)
{
events[j] = new ManualResetEvent(false);
ThreadWork tw = new ThreadWork(events[j], countTo);
threads[j] = new Thread(new ThreadStart(tw.DoWork));
}
for (int j=0; j<numThreads; j++)s
{
threads[j].Start();
}
WaitHandle.WaitAll(events);
Console.WriteLine("Expected:{0} Actual:{1}",
numThreads * countTo, ThreadWork.GlobalCounter);
}
}
ロックを使用すると、ロックの開始と終了に時間がかかること、およびロックの結果スレッドのブロックが発生した場合には、通常、スレッドのコンテキスト スイッチが発生する、という 2 つのことから、実行速度が低下します。さらに、ハイパースレッディングのシステムでは、プロセッサ上で実行されているスレッドの 1 つがブロックされると、L2 キャッシュの利用効率が低下します。ロックの回数とロックの持続時間を最小化することで、パフォーマンスが向上します。ロックを最小化するには、各スレッドに別個のインスタンスを更新させ (図 2 で採用した方法です)、wi1.Result と wi2.Result の独立を維持し、計算が完了した後にのみ結果を生成するようにします。さらに、ロックの持続時間を最小化することで、スレッドがブロックする可能性を減らすことができます。
書き込み可能な共有データを最小限使用することでコードを再構築したり、共有データの更新を遅らせることができる場合は、パフォーマンスを向上させることができる可能性があります。図 6 (これは、C# コンパイラによって Monitor を使用するように変換されます) では、より負荷の大きなロック メソッドを使用していますが、ループの内側から共通のメモリ参照とインタロック操作を除去することで、コードの実行速度を向上させることができます。Interlock.Increment が Monitor.Enter よりも非常に高速であるとしても、変数をインクリメントすることに比べれば非常に低速であり、また単一プロセッサのシステムよりもハイパースレッディングやマルチプロセッサのシステムでいっそう低速になります。
図 6 ロックの実装
private object syncObj;
public void DoWork()
{
int counter = 0
for(int i = 0; i<countTo; i++)
{
counter++;
}
lock(syncObj)
{
ThreadWork.GlobalCounter += counter;
}
manualEvent.Set();
}
ページのトップへ
8. 偽共有とキャッシュのエイリアス
スレッド間で共有されるデータは、変更しないようにする必要があります。それには、スレッドごとに結果のローカル コピーを保持し、スレッドの実行が完了した時点で結果をマージする方法が役立ちます。この方法は、変更されたデータのサイズに制限がある場合に特に役立ちます。これは、アプリケーションの動作サイズの増大もパフォーマンスに影響するためです。キャッシュ ラインと局所性については、すでに説明しました。次のようなクラスが存在するとします。
class OClass
{
public int Field1;
public int Field2;
}
1 つのスレッドが OClass のインスタンスから Field1 を参照することでデータを読み込み、同じ CPU 上の別のスレッドが Field2 に割り当てることで近接するメモリに書き込んだ場合、(Field1 と Field2 が十分近ければ) Field1 と Field2 がキャッシュ ラインを共有する可能性があります。これにより、パフォーマンスが低下します。複数の物理 CPU を持つマシンでは、この問題はさらに悪化します。
もう 1 つのベスト プラクティスは、キャッシュ ラインを共有するメモリ アドレスに、2 つのスレッドから同時に書き込まないようにすることです。一般的な回避策はパディングです。OClass の Field1 と Field2 の間に追加のフィールドがあれば、キャッシュの共有は発生しない可能性があります。この問題は、連続的に割り当てられた 2 つのインスタンスにアクセスする場合に多く見られます。これは、CLR がそれらのインスタンスを隣接するメモリ領域に配置するためです。一緒に割り当てられたオブジェクトは通常は一緒に使用されること、および最初のオブジェクトがアクセスされた後には隣接するオブジェクトもキャッシュ内にある可能性が高いことから、この配置は一般にはパフォーマンスを向上させます。しかし、複数のスレッドがこれらのオブジェクトのフィールドにアクセスしようとすると、キャッシュ ラインが共有されるため、パフォーマンスが低下します。このことを、"偽共有" と呼びます。クラスに (フィールドを追加して) パディングすることが役立ちます。また、これらの要素が連続して割り当てられないようにする方法もあります。
とはいえ、パフォーマンスの向上を期待して、クラスに使用しないフィールドを追加することは避けてください。通常はこの方法でパフォーマンスは向上しませんし、保守しにくい乱雑なコードが生み出される元になります。この問題を取り上げた理由は、システム内部の見えない部分で何が起こっているかを理解し、問題が発生した際にどこを調べればよいかを知ることが重要であるためです。アプリケーションの測定を行い、プロファイルを作成した後、特定のクラスに使用しないフィールドを追加することに決めた場合は、それらのフィールドがコンパイラによって再配列されないように、明示的なレイアウトの使用を検討してください。また、ガベージ コレクションの効果も考慮する必要があります。オブジェクト O1、O2、および O3 が連続して割り当てられている場合、O1 と O3 はキャッシュ ラインを共有せずに開始されます。しかし、O2 への参照が存在しない場合には、O2 はガベージ コレクションの対象となり、O1 は O3 の隣に置かれることになります。
もう 1 つの考慮事項として、エイリアス問題と呼ばれる一連の効果があります。キャッシュ容量のエイリアス問題は、N ウェイ アソシエイティブ キャッシュと呼ばれる CPU キャッシュのアーキテクチャが原因で発生します。別々のメモリ領域 (8KB の L1 キャッシュでは 2KB の倍数で分割され、サイズが 512KB の L2 キャッシュでは 64KB の倍数で、1MB の L2 キャッシュでは 128KB の倍数で、というように分割されます) にあるデータは、キャッシュ ラインの場所を共有し、n 箇所のキャッシュ スポットをめぐって競合します (n は通常は 2、4、または 8 です)。たとえば、アドレス 0x00010、0x08010、0x38010、および 0x90010 (各アドレスは 16 進表記) にあるデータは、32KB (0x8000) の倍数で分割され、L2 キャッシュの場所を共有します。そのため、これらのアドレスのうちの n か所よりも多い箇所が (1 つまたは複数のスレッドによって) 繰り返しアクセスされた場合、それらの一部をメイン メモリから読み取る必要が生じ、パフォーマンスが低下します。さらに別のエイリアス問題は、最新のものを除くすべての Pentium 4 プロセッサで、メモリ アドレスが 64KB の倍数で分割される場合に発生します。
これはアンマネージ コードだけの問題ではなく、マネージ コードにも影響します。C# でサイズが 4 バイトの整数の配列があり、プログラムのフローでは、スレッド B が要素 [i+16KB] にアクセスすると、スレッド A が要素 [i] にアクセスする、という場合、アクセスされるメモリ領域は 64KB 離れています。同時実行されるスレッドから参照されるオブジェクトを、メモリ内の 16KB (またはその倍数) 離れた位置に配置することは避けてください。また、あまり一般的でない CPU リソースの問題もいくつか存在します。一般に、CPU に負担をかける操作の場合は、データを共有する同時実行スレッドの数を 4 ~ 8 に制限するようにしてください。スレッド操作が大量の I/O を利用する場合や、ブロック操作を実行する場合には、より多くのスレッドを使用できます。
CPU のキャッシュ エントリは 1 つの論理 CPU にタグ付けされているわけではないため、論理 CPU 1 で実行されているスレッドが、論理 CPU 2 で実行されている第 2 のスレッドに必要なデータをキャッシュから書き出すために十分なメモリにアクセスすることができます。アプリケーションのスレッドが大きなメモリ領域にアクセスし、追加の作業 (たとえばメモリの大きなブロックをコピーする、整数の配列内の値を合計する、など) をほとんどしない場合には、同じ物理 CPU 上で複数のスレッドを実行しても、パフォーマンスは向上せず、かえって低下する可能性もあります。そのようなアプリケーションについては、ハイパースレッディングを有効にした状態と無効にした状態で、パフォーマンスを測定してください。
ページのトップへ
9. 競合の発生
ハイパースレッディングを有効にした場合、すべての CPU 操作が同じように作成されるわけではありません。すでに説明したとおり、ハイパースレッディングを使用した環境でのパフォーマンスを最適化するには、共有 CPU リソースをめぐるスレッド間での競合を最小化する必要があります。CPU キャッシュのほかに、最も多く競合の対象となるリソースは、浮動小数点演算を実行する FPU です。両方の論理 CPU で同時に大量の浮動小数点演算を実行すると、パフォーマンスが大きく低下します。
図 2 のコード サンプルでの計算を浮動小数点演算を使用するように変更すると (図 7 参照)、タスクを複数のスレッドに分割した効果は減少し、CPU リソースの非効率的な使用が原因で、実行されているスレッドが浮動小数点演算以外には何も実行しない場合には、実行速度が最大 40% 低下することもあります。
Pentium 4 には FPU は 1 つしかないため、浮動小数点演算の実行を両方の論理 CPU にスケジュールしても、パフォーマンスはほとんど向上しません。浮動小数点演算を、物理 CPU あたり 1 つのスレッドだけで実行するようにすると、パフォーマンスが向上する場合があります。
図 7 浮動小数点演算
public float[] records;
void DoWork (int firstElement, int lastElement, out int result)
{
result = 0;
for (int i=firstElement; i<=lastElement; i++)
{
float tempvalue = records[i];
for (int j=0; j<1000; j++)
{
tempvalue = (Math.Sin(tempvalue) * 3 /2) +
((tempvaluei * tempvaluei) % 70) + 1;
}
result += tempvalue;
}
}
ページのトップへ
10. サーバー アプリケーションとハイパースレッディング
多数の要求を同時に処理するサーバー アプリケーションは、多くの場合、ワーク アイテムをスレッド プールのキューに格納することで、すべての論理 CPU を使用し、各操作を別個のスレッドで実行します。そのため、多くのサーバー アプリケーション (たとえば ASP.NET アプリケーション) は、コードを変更しなくても、ハイパースレッディングの恩恵を受けることができます。サーバー アプリケーションをハイパースレッディング対応マシン向けに最適化する場合の考慮事項の 1 つは、すでに説明した共有データの問題です。
ASP.NET では、Web ガーデン構成を考慮してください。Web ガーデンの既定の構成では、論理 CPU へのアフィニティを持つプロセスは、それ専用のデータのコピーを保持し、別個の一連の要求に対してサービスを提供します。ASP.NET の Web ガーデン構成は、物理 CPU ではなく論理 CPU に基づいているため、多くの場合すべての論理 CPU が使用されますが、1 つの物理 CPU 上の論理 CPU 間の共有キャッシュは使用されません。
ページのトップへ
11. CPU アフィニティ
スレッドは通常、システム内で使用可能ないずれかの論理 CPU で実行されるようにスケジュールされます。しかし、スレッドがどの CPU で実行されるかを、より精密に制御する必要がある場合もあります。これを行うには、アフィニティを使用します。
CPU アフィニティは、スレッドまたはプロセスが実行を許可されている CPU を表すビットマスクです。この情報を取得するには、Win32 API の GetProcessAffinityMask に対する P/Invoke を次のように実行します。
[DllImport("kernel32.dll", SetLastError=true)]
static extern int GetProcessAffinityMask (int hProcess,
ref int lpProcessAffinityMask, ref int systemAffinityMask);
第 3 のパラメータ systemAffinityMask の戻り値は、マシン上にある論理 CPU を表すビットマスクです。Windows の現在のすべてのバージョンでは、論理 CPU には 0、1、2、... と番号が付けられます。
ハイパースレッディング非対応の単一物理 CPU のマシンでは、systemAffinityMask の下位ビットだけが設定され、値は 1 となります。ハイパースレッディング対応の単一物理 CPU のマシン、またはハイパースレッディング非対応のデュアル物理 CPU のマシンでは、両方の下位ビットが設定され、systemAffinityMask は 1+2=3 となります。ハイパースレッディング対応のデュアル物理 CPU のマシンでは、systemAffinityMask は 1+2+4+8=15 となります。
ハイパースレッディングを有効にすると、オペレーティング システムは各論理 CPU に番号を割り当てます。n 個の物理 CPU を搭載したシステムでは、第 1 の物理 CPU は、論理 CPU 0 および論理 CPU n として公開されます。第 2 の物理 CPU は、論理 CPU 1 および論理 CPU n+1 として公開されます。つまり、物理 CPU [A, B] は、論理 CPU [0(A), 1(B), 2(A), 3(B)] として公開されることになります。 返された systemAffinityMask を使用すると、設定されているビットの数を数えるだけで、論理 CPU の数を簡単に知ることができます。(この情報は、他のいくつかの Windows API 呼び出しによって入手することもできます。ただし、プロセス アフィニティ マスクを使用すると、プロセスが使用できる論理 CPU の数が返されるので、こちらの方が便利です。)
図 8 のサンプルは、プロセス アフィニティ マスクの設定方法を示しています。 プロセス アフィニティ マスクを各物理 CPU の 1 つの論理 CPU だけに設定すると、アプリケーションは、ハイパースレッディングが無効の場合と同様の動作をします。
図 8 プロセス アフィニティ マスク
public void SetProcessAffinityToPhysicalCPUForHyperthreadOnly(
int processid)
{
int res;
int hProcess;
int ProcAffinityMask = 0, SysAffinityMask = 0;
hProcess = OpenProcess(PROCESS_ALL_ACCESS, 0, processid);
res = GetProcessAffinityMask(
hProcess, ref ProcAffinityMask, ref SysAffinityMask);
if (SysAffinityMask == 3) // 1 プロセッサ、2 論理 CPU
res = SetProcessAffinityMask(hProcess, 1);
else if (SysAffinityMask == 15) //デュアル プロセッサ、4 仮想 CPU
res = SetProcessAffinityMask(hProcess, 3);
res = CloseHandle(hProcess);
}
ただし、1 つ注意点があります。図 8 のコードは、ハイパースレッディングが有効であることを前提しています。参照されるプロセス、およびそこから生成されるすべてのプロセスは、番号の小さい方の論理 CPU でのみ実行されます。このアプローチを使用することで、マシン上でハイパースレッディングを再設定しなくても、ハイパースレッディングが無効な状態でのアプリケーションのパフォーマンスを見積もることができます。また、このアプローチは、パフォーマンスに関する他のソリューションを試してみる場合にも役立ちますが、アプリケーションをハイパースレッディング対応マシンとハイパースレッディング非対応マシンの両方でテストすることは、必須の要件であると考える必要があります。
(processAffinityMask で返された) プロセス アフィニティ マスクが、システム アフィニティ マスクと異なる場合には、プロセスのスレッドは選択された論理 CPU でのみ実行できます。これを行うには、タスク マネージャのユーザー インターフェイスを使用するか、または SetProcessAffinityMask メソッドを呼び出します。タスク マネージャでプロセス アフィニティ マスクを変更するには、[プロセス] タブでプロセスを右クリックし、[set Affinity] をクリックします。
同様に、SetThreadAffinityMask を呼び出すことで、スレッドが特定の CPU でのみ実行されるように制限することができます。ThreadPool スレッドのアフィニティは、変更してはいけません。これは、このスレッドが ThreadPool によって再使用されるためです。このアプローチは、ホストされた状況ではお勧めできません。また、これは当該のマネージ スレッドが基礎となる OS スレッドに結びつけられていることが前提ですが、常にそうであるとは限りません。たとえば、SQL Server 2005 でストアド プロシージャとして実行されるコードを作成した場合、マネージ スレッドはファイバにマップすることができるため、特定の OS スレッドに関係する物事について、削除したり、依存したりすることは避ける必要があります。
何らかの理由で、どの操作がどの論理 CPU で実行されるかを制御する必要がある場合は、論理 CPU と同じ数のスレッドを生成し、第 1 のスレッドを第 1 の論理 CPU に限定し、第 2 のスレッドを第 2 の論理 CPU に限定する、というようにすることができます。スレッド アフィニティ マスクを設定した場合、マスク内の論理 CPU が使用できない場合には、このマスクは実行されません。この状況でのパフォーマンスおよび動作のバグを回避するためには、P/Invoke で SetThreadIdealProcessor メソッドにアクセスすることにより、"ソフト" アフィニティを使用することをお勧めします。
スレッド イデアル プロセッサを設定すると、オペレーティング システムは、イデアル プロセッサが使用可能になると直ちにスレッドをそのイデアル プロセッサにスケジュールしますが、イデアル プロセッサが使用可能でない場合は、使用可能な他の CPU でそのスレッドを実行します。
ページのトップへ
12. Producer/Consumer
非常に効率的でスケーラブルなキャッシュの使用方法として、Producer/Consumer スレッド パターンのバリエーションがあります。このパターンでは、"Producer" スレッドはいくつかのデータを変換し、変換したデータをバッファに格納した後、次のデータ処理します。第 2 のスレッド (Consumer) は、バッファ内のデータを処理し、最終出力を生成します。
このバリエーションを使用すると、Consumer と Producer が同じ物理 CPU で実行されていれば、Consumer スレッドが処理するメモリは、すでに CPU キャッシュに入っている可能性が高くなります。Producer および Consumer スレッドは、別個の論理 CPU で同時に実行することができます。Producer または Consumer が I/O を使用する場合、マルチプロセッサ マシンにスケーラブルに適合するためには、複数の Producer および Consumer スレッドを使用できます。同じデータの Producer と Consumer が同じ物理 CPU で実行されていれば、パフォーマンスが向上します。このバリエーションは、作業負荷を前もって正確に分割できない場合 (たとえば、一部のデータ要素がより多くの処理を必要とするが、データ要素をスレッドに割り当てる前には、どの要素がより多くの処理を必要とするかがわからない場合) にも役立ちます。
このパターンは、ストリームの処理にも役立ちます。また、データの個別のセグメントに対して複数のスレッドを実行することが非効率である場合や、Consumer (または Producer) の計算が浮動小数点演算に大きく依存する場合にも使用できます。Producer/Consumer パターンを実装するために必要となるコードは、この記事で説明できる範囲を超えています。
ページのトップへ
13. ハイパースレッディングの検出
通常、アプリケーションを論理プロセッサの数に基づいてスケーリングすることに問題は発生しないため、ハイパースレッディングの検出は、一般には必要ありません。検出が必要になる場合、マネージ コードから検出を行うことは困難です。
Windows Server 2003 では、この情報は GetLogicalProcessorInformation を P/Invoke することによって公開されます。ただし、この API は Windows XP では利用できません。Windows XP でこの情報を取得するには、CPUID アセンブラ命令を直接使用しますが、この記事では、このアプローチの詳細については取り上げません。
ページのトップへ
14. まとめ
ハイパースレッディングは、複数の相互依存する論理 CPU をデスクトップ マシンに導入するための最初のステップです。この記事では、実行を複数のスレッドに分割することでコードをハイパースレッディング対応マシン向けに最適化する、さまざまな方法について説明しました。将来の CPU 設計では、マルチスレッドは、サーバー アプリケーションとデスクトップ アプリケーションの両方で、アプリケーションのパフォーマンスを向上させるための、さらに役立つツールとなっていくことでしょう (Herb Sutter 著の、同時実行に関する記事「The Free Lunch Is Over: A Fundamental Turn Toward Concurrency in Software」 (英語) を参照してください)。すべての開発者は、マルチスレッドを道具箱に追加しておくことで、恩恵を受けることができます。
Yaniv Pessach は、ソフトウェア業界歴 13 年のベテランで、マイクロソフトで Indigo Web サービスのソフトウェア デザイン エンジニアとして、ネットワーク プログラミングと、コードのパフォーマンスを向上させる仕事に従事しています。Yaniv の連絡先は、彼の個人的な Web サイト www.yanivpessach.com (英語) です。
この記事は、MSDN マガジン - 2005 年 6 月号からの翻訳です。
ページのトップへ