マルチコアプロセッサ環境で古いプログラムがクラッシュする理由

Windows 9x用に開発された古のソフトウェアや、そこまで古いものではないにしても数年前のプログラムを最近のPCで動かしてみて、一見動いているように見えるけれども不特定のタイミングでクラッシュしてしまったり、だんだんと動作がおかしくなったりするといった経験をしたことはないでしょうか。

ここでは、マルチコアプロセッサ環境で生じやすい問題と、その問題を解消できる可能性のある対策について説明します。ただし、プログラムの実装がわからない場合、このページで述べる対策は問題を「解消できる可能性がある」というだけで、確実な解決法であるとは限らないことに注意してください。

タイムスタンプカウンタ(TSC)

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つのパターンが考えられます。

  1. プロセッサコアの周波数の変化に伴って変化する
  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を使用するプログラムをそのようなプロセッサ上で動作させると、不都合が生じる場合があります。次の図を見てください。

コアごとの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を使用するプログラムがクラッシュしたり動作がおかしくなったりするのは、多くの場合、こうして得られた値が使用されることが原因です。

タイムスタンプカウンタを使用するプログラムで問題を避ける方法

プログラム側での解決策にならない解決策(QueryPerformanceCounter関数)

ハードウェアがサポートしていれば、TSCの代替としてQueryPerformanceCounter関数を使えるかもしれませんが、MSDNライブラリの解説によれば、BIOSやHALのバグによって異なったプロセッサを呼び出した場合に異なった結果が得られる可能性があると書かれているため、結局のところあまり解決にはなっていません。

実際には、QueryPerformanceCouter関数は、次に述べるSetThreadAffinityMask関数などと併用することになります。

プログラム側での解決策(SetProcessAffinityMask/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の場合)。

互換性の設定


脚注

  1. 知っていてもあまり役には立ちませんが、ソフトウェアリセット(INIT命令の実行)ではTSC用のレジスタの状態は変更されません。また、ここでは、電源投入や外部からのリセット信号の入力後に行われるプロセッサの初期化プロセスまでを含めてハードウェアリセットと書いています。リセット中とその直後にレジスタが不定の状態になる時間帯があるはずだとか、コールドブート時の値は不定なのかよとかつまらないいちゃもんをつけないように。
  2. このページを書いている2009年4月現在、コアごとに個別の周波数で動作可能な仕様のマルチコアx86プロセッサは実際に何種類か存在します。「理論上は」と書いたのは、コアごとに個別の周波数制御が実際に可能かどうかはマザーボードやBIOSの仕様に依存するためです。また、個別の制御が可能でなくても、省電力機能の動作によってコアごとのTSC値の増加率が影響を受けるプロセッサも存在します。
  3. マルチプロセッサ環境であっても実際にコードを実行するのはプロセッサに内蔵されたいずれかのコアなので、便宜上、コードの実行に関しては物理的なCPUソケットの数にかかわらずプロセッサコアと呼んでいます。本文中で「プロセッサコア」と表記している場合、次の偏執狂的なリストのうちのどれかを指しています。
    • シングルCPUソケットに実装されたシングルコアプロセッサに内蔵されているコア
    • シングルCPUソケットに実装されたマルチコアプロセッサに内蔵されているコアのうちのいずれかひとつ
    • マルチCPUソケットに実装された複数のシングルコアプロセッサのいずれかに内蔵されているコア
    • マルチCPUソケットにシングルコアプロセッサとマルチコアプロセッサが混在して実装されている場合のいずれかのシングルコアプロセッサに内蔵されているコアもしくはマルチコアプロセッサに内蔵されているコアのうちのいずれかひとつ
    • マルチCPUソケットに実装された複数のマルチコアプロセッサのうちのひとつに内蔵されているコアのうちのいずれかひとつ
  4. Windowsはスレッドをできるだけ前回実行したものと同じプロセッサコアで実行するようにスケジューリングします。ただし、そのコアでしか実行しないわけではありません[5]
  5. CPUID命令はシリアライズを強制するので、その影響によって、ある1回のコードの実行に使用されるコアと次の1回のコードの実行に使用されるコアが異なる可能性が高くなっているかもしれません。ただし、RDTSC命令はシリアライズされないので、RDTSCを複数回実行した結果が必要な場合は、CPUID命令などでシリアライズしてからRDTSCを実行するようコーディングするのが一般的で、結局のところ実際に使用される場面に近い状況になっていると思います。
  6. 既に述べたようにTSCはプロセッサコアの動作周波数と連動しているとは限りませんが、TSCのカウントアップ率が変化するプロセッサでは、その値を割り出すための時間Δtの間に、実際のカウントアップ率が変化している可能性があります。したがって厳密にはΔtの間の平均カウントアップ率というのが正しいのですが、本文中では単にカウントアップ率と書いています。
  7. このページを書いているのは2009年4月ですが、ここで解説したような問題を避けるためか、最近のプロセッサでは、動作周波数の変化によらずTSCのカウントアップ率を一定とする仕様が主流のようです。マルチコアプロセッサがそのような仕様の場合に上記の説明が当てはまるのはおかしいと感じるかもしれませんが、各々のコアに着目したときのTSCカウントアップ率は一定でも、その一定のカウントアップ率がコアによって異なっている可能性があるからです(実際にそういう環境もあります)。さらにいえば、マルチプロセッサ環境で各々のプロセッサの一定のTSCカウントアップ率が異なっていたりするかもしれません。
  8. TSC値が表現可能な範囲を超えるとラップアラウンドが生じますが、冒頭に述べたようにTSC用には一般に64ビットのレジスタが使用されているので、3GHzのプロセッサでも約190年間はラップアラウンドが生じることはありません。したがって現実的には単一のコアでTSC値が以前に読み出したものよりも小さい場合は考慮不要です(単一コアじゃない場合にそれが起こるからこのページを書いたわけですが)。

参考文献

  1. Intel Corporation, Intel® 64 and IA-32 Architectures Software Developer’s Manual Volume 3B: System Programming Guide, Part 2 253669-026US, February 2008
  2. インテル株式会社, インテル® プロセッサの識別とCPUID命令 アプリケーション・ノート 485, 2005年3月
  3. Advanced Micro Devices Inc., CPUID Specification, Publication # 25481 Revision: 2.26 July 2007
  4. Advanced Micro Devices Inc., Software Optimization Guide for AMD Family 10h Processors, Publication # 40546 Revision: 3.05 December 2007
  5. Jeffrey Richter, Christophe Nasarre, 株式会社クイープ訳、Advanced Windows 第5版 上、日経BPソフトプレス、2008年10月

Already Exists