Windows 9x用に開発された古のソフトウェアや、そこまで古いものではないにしても数年前のプログラムを最近のPCで動かしてみて、一見動いているように見えるけれども不特定のタイミングでクラッシュしてしまったり、だんだんと動作がおかしくなったりするといった経験をしたことはないでしょうか。
ここでは、マルチコアプロセッサ環境で生じやすい問題と、その問題を解消できる可能性のある対策について説明します。ただし、プログラムの実装がわからない場合、このページで述べる対策は問題を「解消できる可能性がある」というだけで、確実な解決法であるとは限らないことに注意してください。
Intel Pentium以降のx86プロセッサは、タイムスタンプカウンタ(TSC)と呼ばれる高精度カウンタを持っています。このカウンタはプロセッサのハードウェアリセット時*1に0にリセットされ、1クロックにつき1ずつ値が加算される整数値となっています[1]。
1クロックに1ずつ値が加算されるということがどのような意味を持つかといえば、たとえば、1GHzのプロセッサでは、TSC値は1秒間で10の9乗(10億)増加します。人間にとってはほんの短い時間のうちに、とてつもなく大きな値になってしまうことがわかると思います。
仮にこの値を32ビット幅のレジスタでカウントすると、プロセッサのリセット後約4秒間でオーバーフローしてしまいます。2GHzや3GHzといったもっと動作周波数の高いプロセッサでは当然単位時間あたりの値の増加はもっと大きく、オーバーフローするまでの猶予は短くなります。そこで、現代のx86プロセッサでは、大きな整数値をカウントするため、モデル固有のレジスタ(MSR)のひとつとしてTSC専用の64ビット幅のレジスタを持つのが一般的です。
タイムスタンプカウンタは本来、プロセッサの動作周波数という単位時間あたりの値の増加が一定の(おそらく、通常はシステム内で最も)高精度なカウンタです。しかし、プロセッサに省電力機能が実装されるようになると、必ずしもそうともいいきれない場面が生まれるようになってきました。
プロセッサの省電力機能は、ほとんどの場合、次の要素から成ります。
ここでは後者のコア電圧の変化については無視して構いませんが、前者に挙げた動作周波数の変化は、TSCを使用するプログラムの動作に重大な影響を及ぼすことがあります。動作周波数が動的に変化する場合、TSC値の増加率には、大きく分けて次の2つのパターンが考えられます。
特にマルチコアプロセッサ環境では、どちらのパターンであっても、TSCの使用がプログラムのクラッシュの原因になる場合があります。
プロセッサの仕様やBIOSの実装にもよりますが、理論上は、マルチコアプロセッサの省電力機能は、内蔵する複数のコアの動作周波数を個別に制御する可能性があります*2。同じように、物理的に複数のCPUソケットを持つマルチプロセッサ環境でも、理論上はCPUソケットごとの負荷状態によって動作周波数が異なっている可能性があります。
したがって、たとえばひと口に3GHzのマルチコアプロセッサといっても、内蔵されている個別のコアのある時点での動作速度は異なっているかもしれません。このことから、TSCのカウントアップ率が動作周波数によって変化するタイプのプロセッサであれば、プロセッサのリセット時から累積されてきたTSC値は、ある時間において、コアごとに異なっていてもおかしくないということが理解できると思います。
まず結論からいってしまえば、プログラム側でプロセスまたはスレッドとプロセッサコア*3との親和性を指定しない場合、マルチコア(あるいはマルチプロセッサ)環境でプログラムコードの一部がどのプロセッサコア上で実行されるかは予測できません。
なぜなら、プロセッサコアとの親和性を指定されていないコードをどのプロセッサコアで実行するかを決めるのはOS(とプロセッサ)の役目であり、OSはコアの負荷状態やプロセッサ上のキャッシュの状態に応じて、システム全体のパフォーマンスを損なわない選択をすべきだからです*4。
しかし、マルチ(コア)プロセッサの性能を生かしてシステム全体のパフォーマンスを向上させるためのこのOSの配慮が、TSCを使用する古いソフトウェアに不幸をもたらします。詳細は後述することにして、まずはプロセッサコアとの親和性を指定されていないプログラムコードの一部がどのコアで実行されるかを予測できないという事実を、実際のプログラムの一例を挙げて見てみましょう。
// laid.cpp
#include <iostream>
#include <intrin.h>
int checkLocalApicId()
{
int info[4];
__cpuid(info, 1);
return (info[1] >> 24) & 0xff;
}
int main()
{
for(int i=0, apid_i=checkLocalApicId(), apid=apid_i; apid==apid_i && i<1000; ++i)
{
apid = checkLocalApicId();
std::cout << apid << std::endl;
}
return 0;
}
Intel Pentium4以降のプロセッサでは、EAXレジスタに1をセットしてCPUID命令を実行すると、EBXレジスタにロードされた値のビット31〜24がプロセッサコアのローカルAPICの物理IDとなります[2] [3](コードではMicrosoft Visual Studioに付属のintrin.hヘッダに含まれている__cpuid関数を使用しています)。上記のコードは、最初に取得したLocal APIC IDと異なるLocal APIC IDが得られるかどうかを1000回試行し、得られればそこで終了します。
実行するたびに結果は異なりますが、AMD Phenom II X4(4コアのマルチコアプロセッサ)上で実行したある1回の結果は次のようになりました。
2 2 1
この環境ではLocal APIC IDは0から3のいずれかの数字になりますが、CPUID命令を単純なループで繰り返し実行するだけでも、どのコアで実行されるかは予想できないということがわかると思います*5。
TSCはその性質上、ある時点の値(値A)を取得し、その後のある時点での値(値B)を取得した上で、値Bと値Aの差分をとるといった手法が頻繁に用いられます。
ここまで説明してきたように、省電力機能を持つプロセッサでは、TSCのカウントアップ率は一定であるとは限りません。さらに、マルチコアプロセッサの場合、ある短い時間帯でのカウントアップ率*6がコアごとに異なるという可能性も充分にあり得ます*7。
高精度カウンタとしてTSCを使用するプログラムをそのようなプロセッサ上で動作させると、不都合が生じる場合があります。次の図を見てください。
話を簡単にするために、Core0とCore1のそれぞれのカウントアップ率は時間によらず一定で、Core0とCore1のカウントアップ率は異なるものとします。最初にRDTSC命令を実行した時点をt1、得られた値を値Aとし、次にRDTSC命令を実行した時点をt1+Δt、得られた値を値Bとして以下の話を進めます。
図に示すように、Core1よりもCore0の方がカウントアップ率が大きいとすると、時間によらず各コアのカウントアップ率は一定の前提なので、この例ではあらゆる時間において、Core0のTSC値の方がCore1のTSC値よりも大きくなります。したがって、カウントアップ率の差とΔtの大きさによって、時間t1でCore0から読み出した値Aよりも、時間Δt経過後にCore1から読み出した値Bの方が小さい可能性があります。
値A>値Bの場合に値Bから値Aを減算した結果、符号有りの整数として扱えば負の値、符号無しの整数であれば結果はオーバーフローしますが、おそらく正の値の最大値から差分を減じた値に近い値になります(実際はプログラムが書かれた言語の規定や実装によりますが、C/C++のほとんどの実装ではそうなると思われます)。値A<値Bであったとしても、その差分がプログラム側で期待している値に近いものであるとはほとんど期待できません。TSCを使用するプログラムがクラッシュしたり動作がおかしくなったりするのは、多くの場合、こうして得られた値が使用されることが原因です。
ハードウェアがサポートしていれば、TSCの代替としてQueryPerformanceCounter関数を使えるかもしれませんが、MSDNライブラリの解説によれば、BIOSやHALのバグによって異なったプロセッサを呼び出した場合に異なった結果が得られる可能性があると書かれているため、結局のところあまり解決にはなっていません。
実際には、QueryPerformanceCouter関数は、次に述べるSetThreadAffinityMask関数などと併用することになります。
最も確実な解決方法は、SetProcessAffinityMask関数またはSetThreadAffinityMask関数を使用して、TSCの値を読み出すプロセッサコアを限定してしまうことです。同一のプロセッサコアであれば、ある時点で読み出したTSC値よりもその後に読み出したTSC値が小さくなることはない*8ので、少なくとも後から読み出した値と最初に読み出した値の差分が負になることはありません。
上でコードの実行されるプロセッサコアを確かめたコードに少し手を加えて、常に同一のプロセッサコアでコードが実行されることを確かめてみましょう。
// laid2.cpp
#include <iostream>
#include <intrin.h>
#include <Windows.h>
int checkLocalApicId()
{
int info[4];
__cpuid(info, 1);
return (info[1] >> 24) & 0xff;
}
int main()
{
SetProcessAffinityMask(GetCurrentProcess(), 1);
for(int i=0, apid_i=checkLocalApicId(), apid=apid_i; apid==apid_i && i<1000; ++i)
{
apid = checkLocalApicId();
std::cout << apid << std::endl;
}
return 0;
}
変更した部分を強調しています。SetProcessAffinityMask関数の2個目の引数には、32ビット(DWORD値)で表現されるプロセッサとの親和性(アフィニティマスク)を渡します。このアフィニティマスクは各ビットがプロセッサコアに対応し、セットされているビットに対応付けられたプロセッサコアのみでそのプロセスが実行されます。例では1を渡しているので、コードはシステムの最初のプロセッサコアでしか実行されません。
実行結果をここに示しても良いのですが、0という1文字だけの行が1000行続く結果を見て興味深いと思ってくれる人がいるとは思えないのでやめておきます。
アフィニティマスクを指定すると、親和性のないコアではコードは実行されなくなります。この点をよく考慮しておかないと、せっかくのマルチ(コア)プロセッサ環境の効率性を犠牲にする結果にもなりかねません。
Intel CoreシリーズやAMD Phenomシリーズなどの最近(これを書いている2009年時点)のプロセッサでは、TSCカウントアップ率は省電力機能などの動作に関わらず一定となっています[1] [4]。
かといってそう簡単にプロセッサを交換できるとも限らないでしょうし、実はこのようなプロセッサでも、BIOSのバグなどが原因でコアごとのTSCカウントアップ率は一定だがその一定のカウントアップ率がコアによって異なるといったことがあり得るので、他の解決策を採らざるを得ない場面も多いでしょう。
プロセッサコアの動作周波数の変化などに応じてTSCのカウントアップ率が変化するプロセッサでは、BIOSの設定等で省電力機能が無効にできる場合は、無効に設定するとTSCを使用しているプログラムの動作が改善されることがあります。しかし、せっかくの省電力機能を無効にするのは積極的な解決策とはいえません。
プロセッサによっては、マルチコアプロセッサでのTSC使用時の問題を解決するためのドライバがベンダから提供されている場合があります。たとえば、AMDはAthlon64 X2のTSC使用時の問題を解決するためのドライバを提供しています。
実行中のプログラムであれば、タスクマネージャの「プロセス」タブでプログラムのイメージ名を選択し、「関係の設定」を選択することで、プロセッサとの親和性を設定することができます。
参考:http://www.atmarkit.co.jp/fwin2k/win2ktips/862affinity/affinity.html
可能であれば、実行ファイル(.exe)のプロパティ画面で「互換性」を"Windows 98 / Windows Me"などに設定することで、アフィニティマスクが1に設定され、システム中の単一のプロセッサコアでしか実行されなくなるようです(画面は英語UIの場合)。