以下は、perfbook の付録Cの kanda.motohiro@gmail.com による全訳です。perfbook の訳の他の部分は、親文書を参照。
付録C なぜメモリバリア?
さて、疑いを知らない哀れな SMPソフトウェア設計者にメモリバリアを与えることにしたのは、どんないかれたCPU設計者なのでしょう?
短く言えば、メモリ参照をリオーダーするとずっと良い性能が可能だからです。そしてメモリバリアは同期プリミティブのように、オーダーされたメモリ参照がその正しい動作のために必要なものの中でオーダリングを強制するために必要です。
この質問にもっと詳しい回答を得るには、CPUキャッシュがどのようにはたらくか、そして特に、キャッシュが本当にうまくはたらくには何が必要かについて十分な理解が必要です。
以下の節は、
1. キャッシュの構造を示します。
2. キャッシュコヒーレンシープロトコルがどのように、それぞれのCPUがメモリのそれぞれの位置の値について同意することを保証するかを説明します。そして最後に、
3. ストアバッファと無効化キューがどのように、キャッシュとキャッシュコヒーレンシープロトコルが高性能を達成するのを助けるかについて概要を示します。
メモリバリアは、良い性能とスケーラビリティを得るのに必要な必要悪であることを見ます。それは、CPUが、自分がアクセスしようとするメモリと、自分達の間にあるインターコネクトの両方よりも一桁速いという事実に基づく悪なのです。
C.1 キャッシュ構造
現代的なCPUは現代的なメモリシステムよりずっと速いです。2006年のCPUはナノ秒あたり10命令を実行できるかもしれません。しかし、メインメモリからデータ要素をフェッチするには何十ナノ秒も必要でしょう。この速度の差、二桁以上の、が、現代のCPUに見られる何メガバイトものキャッシュを生みました。このキャッシュは図C.1が示すようにCPUに関連付けられています。そして典型的には、数サイクルでアクセスできます。
脚注
複数のレベルのキャッシュを使うのが一般的慣習です。小さなレベル1キャッシュがCPUの近くにあって単一サイクルのアクセス時間が可能で、より大きなレベル2キャッシュはより長いアクセス時間を持ちます。大まかに言って、10クロックサイクルでしょう。より高性能なCPUはしばしば3あるいは時には4レベルのキャッシュを持ちます。
データは、CPUのキャッシュとメモリの間を、「キャッシュライン」と呼ばれる固定長のブロックで流れます。それは通常は2の階乗の大きさで、16から256バイトの範囲です。あるCPUが最初にあるデータ要素をアクセスすると、それはそのCPUのキャッシュにはありません。これは、「キャッシュミス」(あるいはもっと特定するなら、「スタートアップ」あるいは「ウオームアップ」キャッシュミス)が起きたことを意味します。このキャッシュミスは、そのCPUがその要素がメモリからフェッチされるまでに、何百サイクルも待つ(あるいは「ストールする」)必要があることを意味します。しかし、その要素はそのCPUのキャッシュにロードされますから、以降のアクセスはそれをキャッシュに見つけることができ、フルスピードで走るでしょう。
しばらく後には、そのCPUのキャッシュは一杯になり、以降のミスは、新しくフェッチした要素に場所を空けるために要素をキャッシュから破棄しなくてはいけないでしょう。そのようなキャッシュミスは「キャパシティミス」と呼ばれます。キャッシュの有限の容量が原因だからです。しかしほとんどのキャッシュは、一杯になっていなくても、新しい要素に場所を空けるために古い要素を破棄しなくてはいけないことがあります。これは、巨大なキャッシュは、図C.2に示すように、チェーンのない固定長のハッシュバケツ(あるいは、CPU設計者の言葉では「セット」)を持つハードウェアハッシュテーブルとして実装されるという事実のためです。
このキャッシュは16の「セット」と、2の「ウェイ」で合計32の「ライン」を持ちます。それぞれのエントリは、単一の256バイトの「キャッシュライン」、それは256バイトにアラインしたメモリのブロックです、を持ちます。このキャッシュライン長は少し大きめですが、16進の計算をずっと簡単にしてくれます。ハードウェアの用語では、これは2ウェイセットアソシアティブキャッシュであり、ソフトウェアのハッシュテーブルで16のバケツを持ち、それぞれのバケツのハッシュチェーンが最大でも2要素に限られているものに似ています。サイズ(今の場合32キャッシュライン)と、アソシアティビティ(今の場合2)をまとめて、キャッシュの「ジオメトリ」と呼びます。このキャッシュはハードウェアで実装されているので、ハッシュ関数は極端に単純です。メモリアドレスから4ビットを抜き出します。
図C.2で、それぞれの箱はキャッシュエントリに対応します。それは256バイトのキャッシュラインを持つことができます。しかし、キャッシュエントリは空のこともあります。図の空の箱で示されるように。残りの箱にはそれが持つキャッシュラインのメモリアドレスがしるし付いています。キャッシュラインは256バイトにアラインしていないといけないので、それぞれのアドレスの低位8ビットはゼロです。そしてハードウェアハッシュ関数の選択は、その上の4ビットがハッシュライン番号に一致することを意味します。
図に示した状況は、プログラムのコードがアドレス 0x43210E00 から 0x43210EFF に位置し、そしてこのプログラムが 0x12345000 から 0x12345EFF にデータをシーケンシャルにアクセスすると起きるでしょう。このプログラムが次に位置 0x12345F00 をアクセスするところだと考えて下さい。この位置はライン 0xF にハッシュし、このラインの両方のウェイは空なので、対応する256バイトのラインを入れることができます。もしプログラムが位置 0x1233000 をアクセスするなら、それはライン 0x0 にハッシュし、対応する256バイトのキャッシュラインはウェイ1に入れられます。しかし、もしプログラムが位置 0x1233E00 をアクセスするなら、それはライン 0xE にハッシュし、新しいキャッシュラインに場所を空けるために既存のラインの一つがキャッシュから追い出されなくてはいけません。もしこの追い出されたラインが後でアクセスされたら、キャッシュミスが起きます。そのようなキャッシュミスは、「アソシアティビティミス」と呼ばれます。
これまで、CPUがデータ要素を読む場合だけを考えてきました。もしそれがライトをしたらどうなるでしょう。全てのCPUがあるデータ要素の値について合意するのは重要なので、あるCPUがそのデータ要素に書く前に、それはまず他のCPUのキャッシュから除かれる、あるいは、「無効化」されなくてはいけません。この無効化が完了したら、そのCPUは安全にそのデータ要素を変更できます。もしそのデータ要素がそのCPUのキャッシュに存在するけれどもリードオンリイである時、この処理は「ライトミス」と呼ばれます。そのCPUがそのデータ要素を他のCPUのキャッシュから無効化を完了したら、そのCPUは繰り返しそのデータ要素を書く(あるいは読む)ことができます。
その後、もし他のCPUの一つがそのデータ要素をアクセスしようとしたら、それはキャッシュミスを起こします。今回は、最初のCPUがそのデータ要素をそれに書くために無効化したからです。この型のキャッシュミスは「コミュニケーションミス」と呼ばれます。それは通常、複数のCPUがそのデータ要素を通信するために(例えば、ロックは、相互排他アルゴリズムを使ってCPU同士が通信するために使われるデータ要素です)使っているからです。
明らかに、全てのCPUがデータのコヒーレントなビューを維持することを保証するために多大な注意を払わないといけません。このフェッチ、無効化、そして書き込みが全部ある時に、データが失われたり、(もしかするとさらに悪いことに)異なるCPUが同じデータ要素について自分のそれぞれのキャッシュにおいて矛盾する値を持つことを想像するのは容易なことです。これらの問題を防ぐのは、「キャッシュコヒーレンシープロトコル」であり、次の節で説明します。
C.2 キャッシュコヒーレンスプロトコル
キャッシュコヒーレンシープロトコルはキャッシュラインの状態を管理し、データの矛盾あるいは喪失を防ぎます。これらプロトコルは何十もの状態を持ち、とても複雑なことがあります。
脚注
Culler 他の [CSG99] 670 と 671 ページの、9状態の SGI Origin2000 と、26状態の Sequent (現在 IBM) NUMA-Q の状態図を参照下さい。どちらの図も、実際の人生よりは大いに単純です。
しかし私達の目的には、4つのMESI キャッシュコヒーレンスプロトコルにだけ注目すれば良いです。
C.2.1 MESI 状態
MESIは、“modified”変更された, “exclusive”排他的, “shared”共用された, そして “invalid”無効 のことです。このプロトコルを使った時にあるキャッシュラインが取ることのできる4つの状態です。なのでこのプロトコルを使うキャッシュはそれぞれのキャッシュラインに、そのラインの物理アドレスとデータに加えて、2ビット状態の「タグ」を維持します。
「変更された」状態のラインは、対応するCPUから最近メモリストアがされ、対応するメモリは他のCPUのキャッシュには現れないことが保証されています。「変更された」状態のラインはこのため、そのCPUに「所有されている」と言われます。このキャッシュはそのデータの最新のコピーを持っているので、このキャッシュが最終的にそれをメモリに書き戻すかあるいは他のキャッシュに手渡す責任があります。それはこのラインを他のデータを保持するために再使用する前にする必要があります。
「排他的」状態は「変更された」状態にとても似ていますが、キャッシュラインが対応するCPUによって変更されていないことだけが違います。それはこのキャッシュラインのデータのコピーで、メモリに存在するものは最新だということを意味します。しかし、そのCPUは、他のCPUに問い合わせることなく、このラインにいつでもストアできるので、「排他的」状態にあるラインは対応するCPUに所有されていると言っても良いです。とは言え、メモリの対応する値は最新なので、このキャッシュはこのデータをメモリに書き戻したり、他のCPUに手渡すことなく破棄することができます。
「共用された」状態のラインは、少なくても一つ以上の他のCPUのキャッシュに複製されているかもしれません。なので、このCPUは、そのラインに、まず他のCPUに問い合わせてからでないと、ストアすることを許されません。「排他的」状態と同様に、メモリにある対応する値は最新なので、このキャッシュはこのデータを、メモリに書き戻したり、他のCPUに手渡すことなく破棄することができます。
「無効」状態にあるラインは空です。言葉を代えて言えば、データを持ちません。新しいデータがキャッシュに入る時、それは可能ならば「無効」状態にあったキャッシュラインに置かれます。このアプローチは望ましいです。なぜならば、それ以外の状態にあるラインを置換することは、置換されたラインが将来参照されたならば高価なキャッシュミスになるからです。
全てのCPUはキャッシュラインが持つデータのコヒーレントなビューを維持しなくてはいけないため、キャッシュコヒーレンスプロトコルはキャッシュラインをシステムの中で移動するのを調整するメッセージを提供します。
C.2.2 MESI プロトコルメッセージ
前の節で説明した遷移の多くはCPU間の通信が必要です。CPU達が単一の共有されたバスにいるなら、以下のメッセージで十分です。
・リード:
「リード」メッセージは、読まれるキャッシュラインの物理アドレスを持ちます。
・リード応答:
「リード応答」メッセージは、以前の「リード」メッセージが要求したデータを持ちます。この「リード応答」メッセージは、 メモリから供給される時も、他のキャッシュの一つから供給される時もあります。例えば、キャッシュの一つが要求されたデータを「更新済み」状態で持つなら、そのキャッシュが「リード応答」メッセージを供給しなくてはいけません。
・無効化:
「無効化」メッセージは、無効にされるべきキャッシュラインの物理アドレスを持ちます。すべての他のキャッシュは対応するデータを自分のキャッシュから除いて応答をしなくてはいけません。
・無効化応答:
「無効化」メッセージを受け取ったCPUは指定されたデータを自分のキャッシュから除いた後、「無効化応答」メッセージで応答しなくてはいけません。
・リード無効化:
「リード無効化」メッセージは、読まれるキャッシュラインの物理アドレスを持ちますが、同時に他のキャッシュにそのデータを除くように指示します。なのでそれは、名前の通り、「リード」と「無効化」の組み合わせです。「リード無効化」メッセージは、「リード応答」と「無効化応答」の両方のメッセージを応答中に必要とします。
・ライトバック:
「ライトバック」メッセージは、メモリに書き戻される(そしてもしかすると途中で他のCPUのキャッシュに「スヌープ」されるかもしれない)データとアドレスの両方を持ちます。このメッセージは、キャッシュが他のデータのために場所を空けるために、「更新済み」状態にあるラインを破棄することができるようにします。
クイッククイズC.1
ライトバックメッセージはどこから発生して、どこに向かいますか?
興味深いことに、共有メモリマルチプロセッサシステムは覆いの下では実はメッセージパッシング計算機なのです。ということは、分散共有メモリを使うSMPマシンのクラスタは、システムアーキテクチャの二つの異なるレベルにおいて、共有メモリを実装するためにメッセージパッシングを使っているのです。
クイッククイズC.2
二つのCPUが同じキャッシュラインを同時に無効化しようとしたらどうなりますか?
クイッククイズC.3
巨大なマルチプロセッサにおいて「無効化」メッセージが現れたら、全てのCPUは「無効化応答」をしなくてはいけません。その結果起きる「無効化応答」の「嵐」は、システムバスを完全に飽和させませんか?
クイッククイズC.4
SMPマシンが結局のところ実はメッセージパッシングを使っているのならば、なぜそもそもSMPにこだわるのですか?
C.2.3 MESI 状態図
あるキャッシュラインの状態は、図C.3に示すように、プロトコルメッセージが送受信されると変わります。
この図の遷移の弧は以下の通りです。
・遷移(a)
キャッシュラインはメモリに書き戻されました。しかしそのCPUはそれを自分のキャッシュに保持し、それを変更する権利をさらに持ち続けます。この遷移は「ライトバック」メッセージが必要です。
・遷移(b)
CPUは、それが既に排他的アクセスを持っているキャッシュラインに書きました。この遷移はメッセージの送受信は必要ありません。
・遷移(c)
CPUは、自分が更新したキャッシュラインに対する「リード無効化」メッセージを受けました。このCPUは自分のローカルコピーを無効化して、その後、「リード応答」と「無効化応答」の両方のメッセージを応答しなくてはいけません。これは、データを要求するCPUに送り、自分がもうローカルコピーを持っていないことを伝えます。
・遷移(d)
CPUは、自分のキャッシュに無いデータ要素に対してアトミックなリードモディファイライト操作をします。それは、「リード無効化」を送り、データを「リード応答」から受け取ります。CPUは、「無効化応答」の完全なセットも受け取ったら、遷移を完了できます。
・遷移(e)
CPUは、以前に自分のキャッシュにリードオンリーで存在したデータ要素に対してアトミックなリードモディファイライト操作をします。それは、「無効化」メッセージを送らなくてはいけません。また、遷移を完了するために、「無効化応答」の完全なセットを待たなくてはいけません。
・遷移(f)
どれか他のCPUがキャッシュラインを読んで、それはこのCPUのキャッシュから提供されました。このCPUはリードオンリーのコピーを保持します。メモリに書き戻すこともあるかもしれません。この遷移は、「リード」メッセージを受けたことで始まり、このCPUは要求されたデータを含む「リード応答」メッセージを返します。
・遷移(g)
どれか他のCPUがこのキャッシュラインからデータ要素を読んで、それはこのCPUのキャッシュあるいはメモリから提供されました。いずれの場合も、このCPUはリードオンリーのコピーを保持します。この遷移は、「リード」メッセージを受けたことで始まり、このCPUは要求されたデータを含む「リード応答」メッセージを返します。
・遷移(h)
このCPUは、このキャッシュラインにあるデータ要素にもうすぐ書き込む必要があるとわかりました。このため、「無効化」メッセージを送ります。CPUは、「無効化応答」の完全なセットを受け取るまで遷移を完了できません。あるいは、他の全てのCPUは自分たちのキャッシュから、「ライトバック」メッセージでこのキャッシュラインを追い出します(多分、他のキャッシュラインに場所を空けるために)。なので、このCPUがそれをキャッシュしている最後のCPUです。
・遷移(i)
どれか他のCPUが、このCPUのキャッシュだけが持っているキャッシュラインのデータ要素に対して、アトミックなリードモディファイライト操作をします。なのでこのCPUはそれを自分のキャッシュから無効化します。この遷移は、「リード無効化」メッセージを受けたことで始まり、このCPUは「リード応答」と「無効化応答」の両方のメッセージで応答します。
・遷移(j)
CPUは、自分のキャッシュに無いデータ要素に対してストアをします。それは、「リード無効化」を送ります。データを「リード応答」から受け取ります。CPUは、「リード応答」と、「無効化応答」の完全なセットを受け取るまで遷移を完了できません。キャッシュラインは、おそらく、実際のストアが完了したらすぐに、遷移(b)を経由して、「変更された」状態に遷移するでしょう。
・遷移(k)
CPUは、自分のキャッシュに無いデータ要素をキャッシュラインにロードします。CPUは、「リード」を送り、対応する「リード応答」を受け取ったら、遷移を完了します。
・遷移(l)
どれか他のCPUがこのキャッシュラインのデータ要素に対してストアをします。しかし、それはこのキャッシュラインをリードオンリー状態で保持しています。そのキャッシュラインは他のCPU(このCPUのキャッシュかもしれません)のキャッシュにあるからです。この遷移は、「無効化」メッセージを受けたことで始まり、このCPUは「無効化応答」メッセージで応答します。
クイッククイズC.5
上記の遅延した遷移を、ハードウェアはどのように処理しますか?
訳注
x86 lock プレフィクスは、上記MESIプロトコル処理にどう影響しますか?
「メインメモリのバスをロックして、一つのCPUだけからアトミックにメモリ更新をします。」というのがもともとの意味だったと思いますが、実は、排他的に取った自分だけのキャッシュラインを更新するわけですよね。lock なくてもそう動くのでないですか。
インテルのマニュアルを見たら、P6 以降は何もしない、ようです。
C.2.4 MESI プロトコルの例
次はこれを、キャッシュラインのデータの立場から見てみましょう。それは最初、アドレス0のメモリにあります。そして、4CPUシステムの単一ライン、ダイレクトマップのいろいろなキャッシュを旅していきます。表C.1はこのデータの流れを示します。最初のカラムは操作のシーケンスです。二つ目は、操作をするCPU、三つ目は行われる操作、次の四つはそれぞれのCPUのキャッシュラインの状態(メモリアドレスと、MESI状態)、そして最後の二つのカラムは、対応するメモリ内容が最新か("V") そうでないか("I") を示します。
最初、データが置かれるべきCPUキャッシュラインは、「無効」状態にあり、データはメモリ上で有効です。CPU0がアドレス0にあるデータをロードすると、それはCPU0のキャッシュ上で「共用された」状態になり、メモリ上では有効のままです。CPU3もアドレス0のデータをロードします。するとそれは、両方のCPUのキャッシュで、「共用された」状態であり、メモリ上で有効のままです。次にCPU0は何か他のキャッシュライン(アドレス8)をロードします。すると、アドレス0のデータは無効化によってキャッシュから追い出され、アドレス8のデータと入れ替わります。次にCPU2はアドレス0からのロードをします。しかしこのCPUはすぐにそれにストアする必要があることがわかっているので、「リード無効化」メッセージを使って、排他的コピーを得て、それをCPU3のキャッシュから無効化します(なお、メモリにあるコピーは最新のままです)。次にCPU2は予定していたストアをして、状態を「変更済み」に変えます。メモリにあるデータのコピーはここで古くなります。CPU1はアトミックな加算をします。「リード無効化」を使ってデータをCPU2のキャッシュからスヌープして、無効化します。その結果、CPU1のキャッシュにあるコピーは「変更済み」となります(そしてメモリにあるコピーは古いままです)。最後に、CPU1はアドレス8のキャッシュラインを読みます。それは、「ライトバック」メッセージを使ってアドレス0のデータをメモリに押し戻します。
いくつかのCPUのキャッシュにデータがある状態で終わることに注意下さい。
クイッククイズC.6
CPUのキャッシュを全部、「無効」に戻すのは、どんな操作のシーケンスですか?
C.3 ストアは不要なストールの原因になります
図C.1に示したキャッシュ構造は、あるCPUがあるデータ要素に繰り返しリードとライトをする時には良い性能を提供しますが、あるキャッシュラインへの最初のライトの性能はとても悪いです。これを見るために、図C.4を考えましょう。それは、CPU1のキャッシュに保持されているキャッシュラインにCPU0が書いた時のタイムラインを示します。CPU0は、キャッシュラインに書く前に、それが届くのを待たないといけないので、CPU0はかなりの時間ストールしなくてはいけません。
脚注
一つのCPUのキャッシュから他のへキャッシュラインを転送するのに必要な時間は、典型的には、単純なレジスタからレジスタの命令を実行するのに必要な時間より数桁大きいです。
しかし、CPU0がそんなに長くストールしなくてはいけない本当の理由はありません。結局、CPU1が送るキャッシュラインにどんなデータがあるにしても、CPU0は無条件にそれを上書きしようとしているからです。
C.3.1 ストアバッファ
この不要なライトのストールを避ける一つの方法は、図C.5に示すように、それぞれのCPUとそのキャッシュの間に「ストアバッファ」を加えることですこのストアバッファを加えることで、CPU0は単純にそのライトをストアバッファに記録して実行を続けることができます。キャッシュラインが実際にCPU1からCPU0に届いた時に、データはストアバッファからそのキャッシュラインに移動されます。
クイッククイズC.7
でも、ストアバッファの主な目的が、マルチプロセッサキャッシュコヒーレンスプロトコルにおける応答遅延を隠すためであるならば、なぜユニプロセッサもストアバッファを持つのですか?
ストアバッファはそのCPUにローカルです。あるいは、ハードウェアマルチスレッディングを持つシステムでは、そのコアにローカルです。いずれにしても、あるCPUは自分にアサインされたストアバッファだけをアクセスできます。例えば、図C.5で、CPU0はCPU1のストアバッファをアクセスできませんし、逆もそうです。この制限は、関心を分割することでハードウェアを単純にします。ストアバッファは連続するライトの性能を上げますし、一方、CPU(あるいはコアの場合もあるでしょう)の間の通信の責任は、キャッシュコヒーレンスプロトコルが完全に引き受けています。しかし、この制限があっても、対処しないといけない問題があります。それは次の二つの節で扱います。
C.3.2 ストアフォワーディング
最初の問題、自己一貫性の違反、を見るために、以下のコードを見ましょう。変数 “a” と “b”はどちらも最初ゼロで、変数 “a”を持つキャッシュラインはCPU1に、“b”を持つ方はCPU0にあるとします。
1 a = 1;
2 b = a + 1;
3 assert(b == 2);
アサーションが失敗するのは期待しないでしょう。しかし、図C.5に示したとても単純なアーキテクチャを使うくらいに愚かならば、驚くことになります。そのようなシステムは、潜在的に、以下のイベントのシーケンスを見ることがあります。
1 CPU0が a = 1 の実行を開始します。
2 CPU0は、“a”をキャッシュで探しますが無いことがわかります。
3 なのでCPU0は「リード無効化」メッセージを送って、“a”を含むキャッシュラインの排他的所有権を得ようとします。
4 CPU0は自分のストアバッファに、“a”へのストアを記録します。
5 CPU1は「リード無効化」メッセージを受けて、キャッシュラインを送り、そのキャッシュラインを自分のキャッシュから除いて応答します。
6 CPU0は b = a + 1 の実行を開始します。
7 CPU0はCPU1からキャッシュラインを受け取ります。それはまだ、“a”の値ゼロを持っています。
8 CPU0は“a”を自分のキャッシュからロードして、値ゼロを見ます。
9 CPU0は自分のストアバッファから新しく届いたキャッシュラインにエントリを適用します。“a”の値は、自分のキャッシュで1になります。
10 CPU0は前記“a”からロードした値ゼロに1を加え、それを、“b”を持つキャッシュラインに格納します。(それは、既にCPU0が持っていると仮定しています。)
11 CPU0は assert(b == 2) を実行し、それは失敗します。
問題は、私達が “a” の二つのコピーを持っていることです。一つはキャッシュに、もうひとつはストアバッファに。
この例は、とても重要な保証を破ります。つまり、それぞれのCPUは常に自分自身の操作を、それらがプログラムオーダーで起きたかのように見るべきだというものです。この保証を破るのは、ソフトウェア屋さんにとっては暴力的に直感に反します。あまりにそれがひどいため、ハードウェア屋さんは哀れに思って「ストアフォワーディング」を実装しました。つまり、図C.6に示すように、それぞれのCPUは、ロードを実行する時に、自分のキャッシュだけでなく、自分のストアバッファも参照(あるいは「スヌープ」)します。言葉を代えて言えば、あるCPUのストアは、キャッシュを通過する必要なく、直接、その後のロードにフォワードされます。
ストアフォワーディングがあれば、前記シーケンスの8はストアバッファに“a”の正しい値である1を見つけたはずで、“b”の最後の値は期待されるとおり、2になったはずです。
C.3 ストアバッファとメモリバリア
二つ目の問題、グローバルメモリオーダリングの違反、を見るために、以下のコードシーケンスを見ましょう。変数 “a” と “b”はどちらも最初ゼロです。
1 void foo(void)
2 {
3 a = 1;
4 b = 1;
5 }
6
7 void bar(void)
8 {
9 while (b == 0) continue;
10 assert(a == 1);
11 }
CPU0が foo() を実行し、CPU1が bar() を実行したとします。さらに、“a” を含むキャッシュラインはCPU1のキャッシュだけにあり、 “b”を含むキャッシュラインはCPU0が所有しているとします。すると、操作のシーケンスは以下のようになるでしょう。
1 CPU0が a = 1 を実行します。そのキャッシュラインはCPU0のキャッシュにはないので、CPU0は“a”の新しい値を自分のストアバッファに置いて、「リード無効化」メッセージを送ります。
2 CPU1は、while (b == 0) continue を実行します。しかし、“b”を含むキャッシュラインは自分のキャッシュにはないので、「リード」メッセージを送ります。
3 CPU0は b = 1 を実行します。それは既にこのキャッシュラインを所有します(言葉を代えて言えば、そのキャッシュラインは既に「変更された」あるいは「排他的」状態です)。なので、それは“b”の新しい値を自分のキャッシュにストアします。
4 CPU0は「リード」メッセージを受けて、今変更された“b”の値を含むキャッシュラインをCPU1に送ります。さらに、そのラインを自分のキャッシュで「共用された」にしるしづけます。
5 CPU1は“b”を含むキャッシュラインを受け取って、自分のキャッシュに入れます。
6 CPU1は while (b == 0) continue の実行を終わることができるようになりました。そして“b”の値が1だとわかるので、次の文に進みます。
7 CPU1は assert(a == 1) を実行します。CPU1は“a”の古い値で作業しているので、このアサーションは失敗します。
8 CPU1は「リード無効化」メッセージを受けます。そして“a”を含むキャッシュラインをCPU0に送ります。さらに、そのキャッシュラインを自分のキャッシュで無効にします。でもそれは遅すぎます。
9 CPU0は“a”を含むキャッシュラインを受け取って、バッファされたストアを適用しますが、CPU1の失敗したアサーションの犠牲を免れるには遅すぎました。
クイッククイズC.8
前記ステップ1で、なぜCPU0は単純な「リード」の代わりに「リード無効化」を発行する必要がありますか?
ハードウェア設計者はここでは直接助けにはなりません。CPUはどの変数が関連づいているかについては何も知りません。まして、どのように関連しているかもわかりません。なので、ハードウェア設計者はメモリバリア命令を提供して、ソフトウェアがCPUにそのような関係を知らせることができるようにしました。このプログラム断片は、メモリバリアを含むように変更されなくてはいけません。
1 void foo(void)
2 {
3 a = 1;
4 smp_mb();
5 b = 1;
6 }
7
8 void bar(void)
9 {
10 while (b == 0) continue;
11 assert(a == 1);
12 }
メモリバリア smp_mb() は、CPUが、自分の変数のキャッシュラインに以降に続くストアを適用する前に、自分のストアバッファをフラッシュするようにします。そのCPUは先に進む前に、ストアバッファが空になるまで単純にストールすることもできますし、ストアバッファにある以前の全てのエントリが適用されるまで、その後のストアをストアバッファに保持することもできます。この後者のアプローチを使うと、操作のシーケンスは以下のようになるでしょう。
訳注
ステップ1と2は前項と同じです。
3 CPU 0 は、 smp_mb() を実行して、現在のストアバッファの全てのエントリ(つまり、a = 1 のやつです)にしるしをつけます。
4 CPU0は b = 1 を実行します。それは既にこのキャッシュラインを所有します(言葉を代えて言えば、そのキャッシュラインは既に「変更された」あるいは「排他的」状態です)。しかし、ストアバッファにしるしのついたエントリがあります。なので、“b”の新しい値をキャッシュラインにストアするのではなく、それをストアバッファに置きます(ただし、しるしがついていないエントリに)。
5 CPU0は「リード」メッセージを受けて、元の“b”の値を含むキャッシュラインをCPU1に送ります。さらに、そのラインを自分のキャッシュで「共用された」にしるしづけます。
6 は前項5と同じです。
7 CPU1は“b”の値をロードできるようになりました。しかし、それはまだゼロなので、while 文を繰り返します。“b”の新しい値は、CPU0のストアバッファに安全に隠されています。
8 CPU1は「リード無効化」メッセージを受けます。そして“a”を含むキャッシュラインをCPU0に送ります。さらに、そのキャッシュラインを自分のキャッシュで無効にします。
9 CPU0は“a”を含むキャッシュラインを受け取って、バッファされたストアを適用します。このラインは、「変更された」状態になります。
10 “a” へのストアは、smp_mb() によってしるしづけられた唯一のストアバッファのエントリなので、CPU0は“b”の新しい値もストアできます。ただ、“b” を含むキャッシュラインが今は「共用された」状態にあることだけが違います。
11 CPU0は、このため、CPU1に「無効化」メッセージを送ります。
12 CPU1は「無効化」メッセージを受け取って、自分のキャッシュから“b”を含むキャッシュラインを無効にします。そして、CPU0に「応答」メッセージを送ります。
13 CPU1は、while (b == 0) continue を実行します。しかし、“b”を含むキャッシュラインは自分のキャッシュにはありません。なので、CPU0に、「リード」メッセージを送ります。
14 CPU0は、「応答」メッセージを受け取ります。そして、“b”を含むキャッシュラインを「排他的」状態にします。CPU0は、ここで、“b”の新しい値をキャッシュラインにストアします。
15 CPU0は「リード」メッセージを受けます。そして、CPU1に、“b”の新しい値を含むキャッシュラインを送ります。また、このキャッシュラインの自分のコピーを「共用された」にします。
16 CPU1は“b”を含むキャッシュラインを受け取って、自分のキャッシュにそれを置きます。
17 CPU1は“b”の値をロードできるようになりました。それは、“b”の値が1だとわかるので、while ループを抜けて次の文に進みます。
18 CPU1は assert(a == 1) を実行します。しかし、“a”を含むキャッシュラインは既に自分のキャッシュにはありません。それがこのキャッシュをCPU0からもらった時は、最新の“a”の値を扱っていますから、アサーションは合格します。
ご覧の通り、この処理はかなりの量の帳簿作業を伴います。「a の値をロードする」のように、直感的には単純な事でさえ、シリコンの中では多くの複雑な手順を伴うことがあります。
C.4 ストアシーケンスが不要なストールの元になる
不幸にも、それぞれのストアバッファは比較的小さくないといけません。ということは、ほどほどのストアのシーケンスを実行するCPUはそのストアバッファを一杯にすることがあり得ます(例えば、それらが全部キャッシュミスした場合)。その時点でそのCPUは、実行を続ける前に、ストアバッファを空にするために無効化が完了するのをまたもや待たなくてはいけません。この同じ状況はメモリバリアの直後でも起きることがあります。そこでは、以降の全てのストア命令は無効化が完了するのを待たなくてはいけません。それらのストアがキャッシュミスになってもならなくてもです。
この状況は、無効化応答メッセージがより早く届くようにすれば改善できます。それを達成する一つの方法は、無効化メッセージのCPUごとのキュー、つまり「無効化キュー」を使うことです。
C.4.1 無効化キュー
無効化応答メッセージがそんなに長くかかる一つの理由は、それが、対応するキャッシュラインが実際に無効化されたことを保証しないといけないからです。そしてその無効化はそのキャッシュがビジーであると遅れます。例えば、そのCPUがロードとストアを激しく行っていて、その全てのデータがキャッシュにある時など。さらに、短時間に多数の無効化メッセージが届くと、そのCPUはそれを処理するのに忙しく、他の全てのCPUをストールさせることもあるかもしれません。
しかし、CPUは応答を返す前に実際にキャッシュラインを無効化する必要はありません。その代わりに、そのCPUがそのキャッシュラインに関連する以降の何らかのメッセージを送る前に、無効化メッセージは処理されなくてはいけないという理解のもとで、メッセージをキューすることができます。
C.4.2 無効化キューと無効化応答
図C.7は、無効化キューを持つシステムを示します。無効化キューを持つCPUは、無効化メッセージをキューに置いたらすぐに応答することができます。対応するラインが実際に無効化されるまで待つことはありません。もちろんそのCPUは、無効化メッセージを送る準備をする前に、自分の無効化キューを調べないといけません。もし対応するキャッシュラインのためのエントリが無効化キューにあるなら、そのCPUはすぐに無効化メッセージを送ることはできません。その代わり、その無効化キューのエントリが処理されるまで待たないといけません。
あるエントリを無効化キューに入れることは、本質的には、そのキャッシュラインに関するいかなるMESIプロトコルメッセージを処理する前にも、そのエントリを処理するというそのCPUの約束です。対応するデータ構造がとても競合しているのでない限り、そのCPUはそのような約束のためにわずらわされることはほぼ無いでしょう。
しかし、無効化メッセージが無効化キューにバッファされることがあるという事実は、メモリのオーダリング誤りが起きる機会を増やします。これについては次の節で。
C.4.3 無効化キューとメモリバリア
CPUは無効化要求をキューして、それに直ちに応答するとしましょう。このアプローチは、ストアをしているCPUから見えるキャッシュ無効化遅延を最小にします。しかし、以下の例で見るように、メモリバリアを台無しにすることがあります。
変数 “a” と “b”はどちらも最初ゼロとします。“a”は、リードオンリーで複製され(MESIの共用された状態)、“b”は、CPU0が所有(MESIの「排他的」あるいは「変更された」状態)しているとします。そして、以下のコード断片で、CPU0が foo() を実行し、CPU1が bar() を実行します。
1 void foo(void)
2 {
3 a = 1;
4 smp_mb();
5 b = 1;
6 }
7
8 void bar(void)
9 {
10 while (b == 0) continue;
11 assert(a == 1);
12 }
すると、操作のシーケンスは以下のようになるでしょう。
1 CPU0が a = 1 を実行します。そのキャッシュラインはCPU0のキャッシュにリードオンリーであるので、CPU0は“a”の新しい値を自分のストアバッファに置いて、「無効化」メッセージを送り、そのキャッシュラインをCPU1のキャッシュからフラッシュします。
2 CPU1は、while (b == 0) continue を実行します。しかし、“b”を含むキャッシュラインは自分のキャッシュにはないので、「リード」メッセージを送ります。
3 CPU1は、CPU0の「無効化」メッセージを受け、それをキューし、直ちに応答します。
4 CPU0はCPU1からの応答を受け、前記4行目のsmp_mb() の先に進むことができます。“a”の値を、自分のストアバッファから自分のキャッシュラインに移動します。
5 CPU0は b = 1 を実行します。それは既にこのキャッシュラインを所有します(言葉を代えて言えば、そのキャッシュラインは既に「変更された」あるいは「排他的」状態です)。なので、それは“b”の新しい値をキャッシュラインにストアします。
6 CPU0は「リード」メッセージを受けて、今は更新された“b”の値を含むキャッシュラインをCPU1に送ります。さらに、そのラインを自分のキャッシュで「共用された」にしるしづけます。
7 CPU1はb”を含むキャッシュラインを受け取って、自分のキャッシュに入れます。
8 CPU1は while (b == 0) continue の実行を終わることができるようになりました。そして“b”の値が1だとわかるので、次の文に進みます。
9 CPU1は assert(a == 1) を実行します。CPU1のキャッシュにはまだ“a”の古い値があるので、このアサーションは失敗します。
10 アサーションが失敗しても、CPU1は、キューされた「無効化」メッセージを処理します。そして、(遅すぎるのですが)“a”を含むキャッシュラインを自分のキャッシュから無効化します。
クイッククイズC.9
C.4.3節の最初のシナリオのステップ1で、なぜCPU0は「リード無効化」でなく「リード」が送るのですか?CPU0は、このキャッシュラインを “a” と共用する他の変数の値も必要ではないですか?
無効化応答を高速化することがメモリバリアを実際には無視されるようにするならば、明らかにそれはあまり意味の無いことです。しかし、あるCPUがメモリバリアを実行した時に、現在そのCPUの無効化キューにある全てのエントリにしるしをつけ、以降の全てのロードが、しるしのついた全てのエントリがそのCPUのキャッシュに適用されるまで待つことを強制するように、メモリバリア命令と無効化キューを相互作用させれば良いでしょう。なので、以下のように、関数 bar にメモリバリアを追加しましょう。
1 void foo(void)
2 {
3 a = 1;
4 smp_mb();
5 b = 1;
6 }
7
8 void bar(void)
9 {
10 while (b == 0) continue;
11 smp_mb();
12 assert(a == 1);
13 }
クイッククイズC.10
何ですって???このCPUは while ループが完了するまで assert() を実行することはありえません。なのになぜここで、メモリバリアが必要なのですか?
この変更をすると、操作のシーケンスは以下のようになるでしょう。
訳注
1から8の前半までは、前項と同じなので略します。
8の後半。それは今回は、メモリバリアです。
9 CPU1は今回は、自分の無効化キューの既存のエントリを全て処理するまでストールしなくてはいけません。
10 CPU1は、キューされた「無効化」メッセージを処理します。そして、“a”を含むキャッシュラインを自分のキャッシュから無効化します。
11 CPU1は、assert(a == 1) を実行します。“a”を含むキャッシュラインはもうCPU1のキャッシュには無いので、「リード」メッセージを送ります。
12 CPU0はこの「リード」メッセージに、“a”の新しい値を含むキャッシュラインで答えます。
13 CPU1はこのキャッシュラインを受け取ります。それは、“a”の値として1を持ちます。なので、アサーションは発火しません。
多くのMESIメッセージのやり取りの後、CPUは正しい結果にたどり着きました。この節は、なぜCPU設計者がキャッシュコヒーレンスの最適化に極めて注意深くあらねばいけないかを明らかにしました。
C.5 リードとライトのメモリバリア
前の節で、メモリバリアは、ストアバッファのエントリにしるしをつけるためにも、無効化キューのエントリにしるしをつけるためにも使われました。しかし、そのコード断片において、 foo() は、無効化キューについて何かをする理由は無いですし、同様に bar() はストアバッファについて何かをする理由はありません。
なので、多くのCPUアーキテクチャは、これらのうちどちらか一つだけを行うより弱いメモリバリア命令を提供します。大まかに言って、「リードメモリバリア」は無効化キューだけにしるしをつけ、「ライトメモリバリア」は、ストアバッファだけにしるしをつけます。完全なメモリバリアは両方をします。
この結果、リードメモリバリアは、それを実行したCPUのロードだけをオーダーして、そのリードメモリバリアの前にある全てのロードがそのリードメモリバリアの後にある全てのロードの前に完了したかのように見せます。同様に、ライトメモリバリアはそれを実行したCPUのストアだけをオーダーして、同様に、そのライトメモリバリアの前にある全てのストアがそのライトメモリバリアの後にある全てのストアの前に完了したかのように見せます。完全なメモリバリアはロードとストアの両方をオーダーします。しかし同様に、それを実行したCPUのものに限ります。
foo と bar を、リードライトのメモリバリアを使うようになおすと、以下のようになります。
1 void foo(void)
2 {
3 a = 1;
4 smp_wmb();
5 b = 1;
6 }
7
8 void bar(void)
9 {
10 while (b == 0) continue;
11 smp_rmb();
12 assert(a == 1);
13 }
計算機によっては、より多くのメモリバリアの種類を持つものもありますが、上記三つの変種を理解すれば、メモリバリア一般についての良い導入となるでしょう。
C.6 メモリバリアシーケンスの例
この節は、魅力的ですがわかりにくいこわれ方をしているメモリバリアの使い方をいくつか示します。その多くは、ほとんどの場合にうまくいきますし、ある特定のCPU上では常にうまくいくものもあります。しかし、全てのCPUで安定して動くコードを作成するのがゴールであるなら、こういう使い方は避けなければいけません。わかりにくいこわれ方をよりよく理解するために、まず、オーダリングに敵対的なアーキテクチャに焦点を当てる必要があります。
C.6.1 オーダリングに敵対的なアーキテクチャ
この数十年に、オーダリングに敵対的な計算機システムがいくつも生産されました。しかし、敵対性の性質は常に極めてわかりにくいもので、それを理解するにはその特定ハードウェアの詳細な知識が必要でした。特定のハードウェアベンダを取り上げて、読者を詳細な技術仕様の中を引き回すというある意味魅力的な代案の代わりに、神秘的で最大限にメモリオーダリングに敵対的な計算機アーキテクチャを設計しましょう。
脚注
本物のハードウェアアーキテクチャを詳しく調べる方が良い読者は、CPUベンダのマニュアル [SW95, Adv02, Int02b, IBM94, LSH02, SPA94, Int04b, Int04a, Int04c].や、Gharachorloo の学位論文[Gha95]、Peter Sewell の論文[Sew]、あるいは、Sorin, Hill, and Wood の素晴らしいハードウェアよりの入門書[SHW11]をご覧になるのをおすすめします。
このハードウェアは以下のオーダリング制約に従わなくてはいけません。
1. それぞれのCPUは常に、自分自身のメモリアクセスはプログラムオーダーで起きているように見えます。
2. CPUがある操作をストアとリオーダーするのは、二つの操作が異なる位置を参照している時に限ります。
3. あるCPUのリードメモリバリア (smp_rmb())の前にある全てのロードは、全てのCPUにとって、そのリードメモリバリアの後にある全てのロードの前にあるように見えます。
4. あるCPUのライトメモリバリア (smp_wmb()の前にある全てのストアは、全てのCPUにとって、そのライトメモリバリアの後にある全てのストアの前にあるように見えます。
5. あるCPUのフルメモリバリア(smp_mb())の前にある全てのアクセス(ロードとストア)は、全てのCPUにとって、そのメモリバリアの後にある全てのアクセスの前にあるように見えます。
クイッククイズC.11
それぞれのCPUが自分自身のメモリアクセスを順序通りに見る保証は、それぞれのユーザレベルスレッドが自分自身のメモリアクセスを順序通りに見ることも保証しますか?なぜですか、あるいはなぜそうでないですか?
巨大な非均一キャッシュアーキテクチャ (NUCA) システムを想像ください。それは、図C.8に示すように、あるノードのCPUに公平なインターコネクトバンド幅の割り当てを提供するために、それぞれのノードのインターコネクトインターフェースに、CPUごとのキューを備えているとします。あるCPUのアクセスは、そのCPUが実行するメモリバリアの指定した通りにオーダーされますが、あるCPU対のアクセスの相対的オーダーは、以降に見るように厳しくリオーダーされることがあります。
脚注
本当のハードウェアアーキテクトあるいは設計者は激しく異議を唱えるのは疑いないでしょう。両方のCPUがアクセスしたキャッシュラインに関するメッセージをどちらのキューが扱うべきであるかを判断する方法は気に入らないでしょうし、この例にある多数の競合は言うまでもありません。私が言うことができるのは、「もっとましな例を教えてください」です。
C.6.2 例1
表C.2は、CPU0,1,2 で同時に実行される三つのコード断片を示します。“a”, “b”, “c” はどれも最初ゼロです。
CPU0は最近たくさんのキャッシュミスを起こしたため、そのメッセージキューは一杯だとします。しかし、CPU1はそのキャッシュの中でだけずっと動いていたため、そのメッセージキューは空だとします。すると、CPU0の “a” と “b” への代入は、直ちにノード0のキャッシュに現れます(なので、CPU1から見えます)。しかし、CPU0の以前の通信の後にブロックされます。それに対して、CPU1の “c” への代入はCPU1の空であったキューを通りぬけて進みます。なので、CPU2は、CPU0の “a” への代入の前に、CPU1の “c” への代入を見ることが十分あり得ます。この結果、メモリバリアがあってもアサーションは発火します。
なので、ポータブルなコードはこのアサーションが発火しないことに依存してはいけません。コンパイラもCPUも、アサーションが
するようにコードをリオーダーするかもしれないからです。
クイッククイズC.12
このコードは、CPU1の “while” と、 “c” への代入の間にメモリバリアを挿入することで修正できますか?なぜですか、あるいはなぜそうでないですか?
C.6.3 例2
表C.3は、CPU0,1,2 で同時に実行される三つのコード断片を示します。“a”, “b” はどれも最初ゼロです。
同じく、CPU0は最近たくさんのキャッシュミスを起こしたため、そのメッセージキューは一杯だとします。しかし、CPU1はそのキャッシュの中でだけずっと動いていたため、そのメッセージキューは空だとします。すると、CPU0の “a” への代入は、直ちにノード0のキャッシュに現れます(なので、CPU1から見えます)。しかし、CPU0の以前の通信の後にブロックされます。それに対して、CPU1の “b” への代入はCPU1の空であったキューを通りぬけて進みます。なので、CPU2は、CPU0の “a” への代入の前に、CPU1の “b” への代入を見ることが十分あり得ます。それは、メモリバリアがあっても、アサーションを発火させます。
理論的には、ポータブルなコードはこの例のコード断片に依存するべきではありません。しかし、前と同様に、実際にはこれはほとんどのメインストリームの計算機システムで動作します。
C.6.4 例3
表C.4は、CPU0,1,2 で同時に実行される三つのコード断片を示します。全ての変数はどれも最初ゼロです。
CPU1も、CPU2も、CPU0が3行目で “b” に代入をするのを見るまで5行目には進めないことに注意下さい。CPU1と2が4行目でメモリバリアを実行した後は、CPU0の2行目のメモリバリアの前にある全ての代入を見ることが保証されます。同様に、CPU0の8行目のメモリバリアはCPU1と2の4行目のものと対になります。このため、CPU0は、それが "b" に代入したものが、他の両方のCPUから見えるようになるまでは "e" への代入を実行しません。
訳注
原文は、a への代入ですが誤りです。
なので、CPU2の9行目のアサーションは発火しないことが保証されます。
クイッククイズC.13
表C.4のCPU1と2の3から5行目が割り込みハンドラにあるとします。そして、CPU2の9行目がプロセスレベルで走るとします。コードを正しく動かす、言葉を代えて言えば、アサーションが発火するのを防ぐ、ためには、どんな変更が、もし必要だとしたら、必要でしょう?
クイッククイズC.14
もし表C.4の例で、CPU2が assert(e==0||c==1) を実行したら、このアサーションははたして発火するでしょうか?
Linuxカーネルの synchronize_rcu() プリミティブは、この例で示したアルゴリズムに似たものを使っています。
C.7 特定CPUのメモリバリア命令
表C.5に示すように、それぞれのCPUは独自のメモリバリア命令を持ちます。それは移植性を困難にすることがあります。実際、pthread や Java を含む多くのソフトウェア環境は、単純にメモリバリアの直接な使用を禁止しています。プログラマは相互排他プリミティブを使うことしかできません。そのプリミティブは自分に必要な限りでメモリバリアを取り込んでいます。表で、最初の4つのカラムはそのCPUがロードとストアの4つの可能な組み合わせのリオーダーを許すかを示します。次の2つのカラムはそのCPUがアトミック命令に関してロードとストアのリオーダーを許すかを示します。
7つ目のカラム、データ依存リードがリオーダーされるか、は少し説明が必要です。以下の、Alpha CPU を扱う節で扱います。短い答えは、Alpha は連結リストデータ構造を更新する時に、更新者だけでなくてリーダーもメモリバリアが必要だということです。はい。これは、Alpha が実際に、ポインタ自身をフェッチするより前にそれが指すデータをフェッチする事があるということです。変ですが、真実です。私がつくり話をしているとお思いなら、http://www.openvms.compaq.com/wizard/wiz_2637.html を見て下さい。この極端に弱いメモリモデルの利点は、Alpha がより簡単なキャッシュハードウェアを使うことができ、その結果、Alpha の全盛期においてより高いクロック周波数を可能としたことです。
最後のカラムはそのCPUがコヒーレントでない命令キャッシュとパイプラインを持つかどうかを示します。そのようなCPUは、自己変更コードの場合、特別な命令を実行する必要があります。
かっこに入ったCPU名はアーキテクチャ的に許されるモードですが、実際にはほとんど使われないものです。
メモリバリアを「単純にだめと言う」アプローチは一般的で、それが使える状況では明らかに合理的かもしれません。しかし、メモリバリアを直接使う必要のある環境は、Linux カーネルのように、いくつかあります。なので、Linuxは注意深く選ばれた、メモリバリアプリミティブの最大公約数のセットを提供します。以下に示します。
• smp_mb():「メモリバリア」は、ロードとストアの両方をオーダーします。これは、このメモリバリアの前にあるロードとストアはこのメモリバリアの後にある全てのロードとストアより前にメモリにコミットされることを意味します。
• smp_rmb():「リードメモリバリア」はロードだけをオーダーします。
• smp_wmb():「ライトメモリバリア」はストアだけをオーダーします。
• smp_read_barrier_depends()は、以前の操作に依存する以降の操作をオーダーします。このプリミティブは、Alpha 以外の全てのプラットフォームで no-op です。
• mmiowb()は、グローバルスピンロックで守られた MMIO ライトのオーダリングを強制します。このプリミティブは、スピンロック内のメモリバリアが既にMMIOオーダリングを強制する全てのプラットフォームで no-op です。mmiowb() 定義が no-op でないプラットフォームは、IA64, FRV, MIPS,そして SH システムの一部(全てではありません)です。このプリミティブは比較的新しいので、比較的少数のドライバだけがそれを活用しています。
smp_mb(), smp_rmb(), そして smp_wmb() プリミティブは、コンパイラがバリアを越えて、メモリアクセスをリオーダーすることになる最適化をやめさせる効果もあります。smp_read_barrier_depends()プリミティブも同じ効果を持ちますが、Alpha CPU に限ります。これらのプリミティブの使用について詳しくは、14.2節を参照下さい。これらのプリミティブはSMP カーネルにおいてだけコードを生成します。しかし、それらはUP バージョン(mb(), rmb(), wmb(), そして read_barrier_depends())もあり、UP カーネルでもメモリバリアを生成します。殆どの場合、smp_ バージョンを使うべきです。しかし、後者のプリミティブは、ドライバを書く時に便利です。MMIOアクセスは、UPカーネルでもオーダーを守らないといけないからです。メモリバリア命令が無い時は、CPUもコンパイラもそのアクセスを喜んでリオーダーします。すると運の良い時にはデバイスが誤動作したりあなたのカーネルがクラッシュすることもあるでしょうし、あなたのハードウェアをこわす場合もあるでしょう。
なので、ほとんどのカーネルプログラマはそれぞれの全てのCPUのメモリバリアの特殊性について心配する必要はありません。これらのインタフェースに固執する限りにおいては。もしあなたが、特定CPUのアーキテクチャ固有コードの奥深くで作業をしているなら、賭けはあなたの負けです。
さらに、Linuxのロックプリミティブ (スピンロック、リーダーライターロック、セマフォー、 RCU, ...)の全てには、必要なメモリバリアプリミティブが全て含まれます。なので、あなたがこれらのプリミティブを使うコードの作業をしているなら、あなたはLinuxのメモリオーダリングプリミティブについて心配する必要は全くありません。
とは言え、それぞれのCPUのメモリコンシステンシーモデルを深く知ることは、デバッグする時にとても助けとなるはずです。アーキテクチャ固有コードや、同期プリミティブを書く時にはもちろんのことです。
ところで、少ない知識はとても危険なことだと言います。多くの知識であなたが起こせる被害のことを想像して下さい!それぞれのCPUのメモリコンシステンシーモデルについてもっと理解したい方のために、以下の節に、最も一般的で著名なCPUについてそれを説明してあります。実際のそのCPUの文書を読むことに代えられることは無いでしょうが、以下の節は良い概説となるでしょう。
C.7.1 Alpha
寿命の終わりが宣言されたCPUについて何か言うことがあるのは変だと思われるかもしれませんが、Alpha は、興味深いです。なぜならば、その最も弱いメモリオーダリングモデルによって、それはメモリ操作を最もアグレッシブにリオーダーします。Linux は Alpha を含む全てのCPUで動作しないといけないため、Alpha はLinux カーネルのメモリオーダリングプリミティブを定義したと言えます。なので、Alpha を理解するのはLinux カーネルハッカーにとって驚くほど重要です。
Alphaと他のCPUの違いは、図C.9に示すコード断片でわかります。この図の9行目の smp_wmb() は、10行目に要素がリストに加えられる前に、6から8行目の要素の初期化が実行されることを保証します。そのため、ロックなしの検索が正しく動くはずです。つまり、それはこの保証をします。Alpha以外のCPUにおいては。
Alphaは極めて弱いメモリオーダリングを持ちますから、図C.9の20行目は、6から8行目の初期化の前にあった古いゴミ値を見ることがあります。
図C.10は、分割されたキャッシュを持つアグレッシブに並列なマシンにおいてどのようにこれが起きるかを示します。そこでは、異なるキャッシュラインは別々のキャッシュのパーティションによって処理されます。リストヘッダ head はキャッシュバンク0で、新しい要素はキャッシュバンク1で処理されるとします。
Alphaでは、smp_wmb() は、図C.9の6から8行目で実行されたキャッシュ無効化が、10行目が行う無効化の前にインターコネクトに達することを保証します。しかし、新しい値が読んでいるCPUのコアに届く順序については絶対的に何も保証をしません。例えば、読んでいるCPUのキャッシュバンク1がとても忙しく、しかしバンク0がアイドルであることは可能です。
この結果、新しい要素のキャッシュ無効化は遅延することがあり、読んでいるCPUはポインタの新しい値を得ますが、新しい要素の古いキャッシュされた値を見ることになります。繰り返しますが、私がこれを全部、作り話をしていると思われるなら、詳しくは前述のウェブサイトを参照ください。
脚注
もちろん、賢明な読者は、Alphaはまだまだ、最高に面倒だというにはほど遠いことに既に気づかれたでしょう。C.6.1節の(感謝すべき)神秘的なアーキテクチャが良い例です。
smp_rmb() プリミティブを、ポインタフェッチとデレファレンスの間に置くこともできます。しかしこれは、リード側のデータ依存を尊重するシステム(i386, IA64,
PPC, SPARC など)で、不要なオーバーヘッドを課します。これらのシステムでオーバーヘッドを除くために、Linux2.6カーネルに smp_read_barrier_depends() プリミティブが追加されました。このプリミティブは、図C.11の19行目に示すように使うことができます。
smp_wmb() の代わりに使えるソフトウェアバリアを実装することもできます。それは全ての読んでいるCPUが書いているCPUのライトを順序通りに見るように強制します。しかしこのアプローチは、Alphaのような極端に弱いオーダーのCPUで過剰なオーバーヘッドを課すものだとLinuxコミュニティに思われました。このソフトウェアバリアは、プロセッサ間割り込み(IPI)を他の全てのCPUに送ることで実装することができます。IPIを受けたCPUは、メモリバリア命令を実行して、キャッシュライン破棄を実装します。
訳注
原文は、memory-barrier shootdown ですが、キャッシュの誤りと思います。
デッドロックを防ぐために追加の論理が必要です。もちろん、データ依存を尊重するCPUはそのようなメモリバリアは単純に smp_wmb() と定義するでしょう。Alphaが夕暮れの中、消えていくにつれて、この決定は将来再考するべきかもしれません。
Linux のメモリバリアプリミティブの名前は、Alpha の命令に由来します。smp_mb() は mb, smp_rmb() は rmb, そして smp_wmb() は wmb です。Alpha は、smp_read_barrier_depends() が、 no-op でなくて、smp_mb() である唯一のCPUです。
クイッククイズC.15
なぜ、Alphaの smp_read_barrier_depends() は、smp_rmb() ではなくて、smp_mb() なのですか?
Alphaについて詳しくは、レファレンスマニュアル [SW95] を参照下さい。
C.7.2 AMD64
AMD64 は x86 と互換です。そして、その文書化されたメモリモデルを更新しました [Adv07]。それはこれまで実際の実装が提供してきた、よりきついオーダリングを強制するものです。Linux smp_mb() プリミティブの AMD64 実装は、mfence で、smp_rmb() は lfence, そして smp_wmb() は sfence です。理論的には、これらはよりゆるくすることができます。しかし、それには、 SSE と 3DNOW 命令の考慮が必要です。
C.7.3 ARMv7-A/R
ARM ファミリーのCPUは、組み込みアプリケーションでとても人気があります。特に、携帯電話のような、電力が限られるアプリケーションでです。それでも、5年以上前からARMのマルチプロセッサ実装は存在します。そのメモリモデルは、Power (C.7.6を参照。ただし、ARM は異なるメモリバリア命令のセットを持ちます [ARM10])に似ています。
1 DMB (data memory barrier)は、指定されたタイプの操作が、その同じタイプの全ての以降の操作の前に完了したように見える効果を持ちます。操作の「タイプ」とは、全ての操作であっても良く、ライトに限られても(Alpha wmb と POWER eieio 命令と同様)良いです。さらに、ARMはキャッシュコヒーレンスが以下の3つのスコープのどれかを持つことを許します。単一プロセッサ、プロセッサのサブセット (“inner”) そしてグローバル (“outer”)。
2 DSB (data synchronization barrier)は、指定されたタイプの操作が、(全てのタイプの)全ての以降の操作が実行される前に実際に完了する効果を持ちます。操作の「タイプ」は、DMB の時と同じです。DSB 命令はARMアーキテクチャの初期のバージョンでは、DWB (drain write buffer あるいは data write barrier, どちらでも)と呼ばれていました。
3 ISB (instruction synchronization barrier)はCPUのパイプラインをフラッシュします。なので、ISBの後にある全ての命令は、ISBが完了した後にならないとフェッチされません。例えば、あなたが自己変更するプログラム(JIT のような)を書いているなら、コードを生成してから実行するまでの間にISBを実行するべきです。
これらのどの命令も、Linuxの rmb() プリミティブのセマンティクスとぴったり一致するものはありません。なのでそれは、完全なDMBとして実装されなくてはいけません。DMBとDSB命令は、バリアの前と後にオーダーされるアクセスの再帰的な定義を持ちます。それは、POWERの累積性と似た効果を持ちます。
ARMは制御依存も実装します。つまり、条件付き分岐がロードに依存するならば、その条件付き分岐の後に実行される全てのストアはそのロードの後にオーダーされます。しかし、その条件付き分岐の後にあるロードは、分岐とロードの間にISB命令がある時以外はオーダーされる保証はありません。以下の例を考えましょう。
この例では、ロードとストアの制御依存オーダリングの結果、1行目の x からのロードは、4行目の y へのストアの前にオーダーされます。しかし、ARMはロードとロードの制御依存を尊重しません。このため、1行目のロードは、5行目のロードの後に起きることが十分にあり得ます。一方、2行目の条件付き分岐と6行目の ISB 命令の組み合わせは、7行目のロードが1行目のロードの後に起きることを保証します。3と4行目の間のどこかに追加の ISB 命令を入れることで、1と5行目のオーダリングが強制されることに注意下さい。
C.7.4 IA64
IA64は、弱いコンシステンシーモデルを提供します。なので、明示的なメモリバリア命令がないときは、IA64は自分の権限でメモリ参照を任意にリオーダーします[Int02b]。
IA64は、mf というメモリフェンス命令を持ちます。しかし、ロード、ストアそしてアトミック命令のいくつかに対して、「半分のメモリフェンス」モディファイアも持ちます [Int02a]。acq モディファイアは、以降のメモリ参照命令が、 acq の前にリオーダーされるのを防ぎますが、以前のメモリ参照命令が acq の後にリオーダーされるのを許します。これは、図C.12に、楽しく描かれたとおりです。同様に、rel モディファイアは、以前のメモリ参照命令が、 rel の後にリオーダーされるのを防ぎますが、以降のメモリ参照命令が rel の前にリオーダーされるのを許します。
これらの半分のメモリフェンスはクリティカルセクションに便利です。操作をクリティカルセクションに押し込むのは安全ですが、それが漏れ出るのを許すのは致命的となることがあるからです。しかし、この性質を持つ唯一のCPUとして、IA64は Linuxのロック確保と解放に関するメモリオーダリングのセマンティクスを定義します。
IA64 mf 命令は、Linuxカーネルの smp_rmb(), smp_mb(), そして smp_wmb() プリミティブに使われています。ああ、逆であるという噂もありましたが、"mf" ニーモニックは本当に「メモリフェンス」の意味だったのです。
最後に、IA64は、"mf" 命令を含む "release" 操作に、グローバルトータルオーダーを提供します。これは、推移律という概念を提供します。つまり、もしあるコード断片があるアクセスが起きたと見るならば、その後の全てのコード断片も以前のアクセスが起きたと見ます。なお、関連する全てのコード断片がメモリバリアを正しく使っているという前提のもとで。
C.7.5 PA-RISC
PA-RISC アーキテクチャはロードとストアの完全なリオーダリングを許しますが、実際のCPUはフルオーダードで走ります[Kan96]。ということは、Linuxカーネルのメモリオーダリングプリミティブはコードを生成しないということです。しかし、gcc memory 属性は、コードをメモリバリアを超えてリオーダーするコンパイラ最適化を無効にするために実際に使われます。
C.7.6 POWER / PowerPC
POWER と PowerPC® CPUファミリーはいろんな種類のメモリバリア命令を持ちます [IBM94, LSH02]。
1. sync は、以前の全ての操作が、全ての以降の操作が始まる前に完了したように見せます。なのでこの命令はとても高価です。
2. lwsync (light-weight 軽量 sync)は、以降のロードとストアに対してロードをオーダーします。そしてストアもオーダーします。しかし、それはストアを以降のロードに対してオーダーしません。とても興味深いことに、 lwsync 命令は、zSeries と、偶然なことに、SPARC TSO と同じオーダリングを強制します。
3. eieio((enforce in-order execution of I/O、I/Oのインオーダーの実行を強制する、なんのことかわからない方へ)は、以前の全てのキャッシュ可能ストアが、全ての以降のストアの前に完了したように見せます。しかし、キャッシュ可能メモリへのストアは、キャッシュ不可能メモリへのストアとは独立にオーダーされます。ということは、eieio は、MMIOストアが、スピンロック解放の前にあることを強制しないということです。
4. lsync は、以前の全ての命令が、全ての以降の命令が実行を開始するより前に完了したように見せます。ということは、以前の命令は、それが生成するかもしれないトラップがすでに起きたかあるいは起きないことが保証され、そしてその命令の全ての副作用(例えば、ページテーブルの変更)が以降の命令から見える地点まで十分に進んでいないといけないことを意味します。
不幸にも、これらの命令のどれも、Linux の wmb() プリミティブとぴったり一致するものはありません。それは、全てのストアがオーダーされるのを要求しますが、sync 命令のそれ以外のオーバヘッドの高いアクションは不要です。しかし、選択肢はありません。ppc64 バージョンの wmb() と mb() は、重い sync 命令に定義されています。しかし、Linux の smp_wmb() プリミティブは決して MMIO には使われません(結局、ドライバは SMP だけでなく UP カーネルでも、注意深く MMIO をオーダーする必要があるからです)。なので、それは、より軽い eieio 命令に定義されています。この命令は、5つの母音を持つニーモニックを持つものとしてもユニークです。smp_mb() プリミティブも sync 命令に定義されていますが、smp_rmb() と rmb() の両方はより軽い lwsync 命令に定義されています。
訳注
原文は、instruction と primitive の使い方が混乱しています。
Power は、 “cumulativity” 累積性を特徴とします。それは、推移律を得るのに使えます。適切に使えば、以前のコード断片の結果を見ているコードは全て、その以前のコード断片自身が見たアクセスも見ることになります。より詳しくは、McKenney and Silvera [MS09] を参照下さい。
Power は、ARM とほとんど同じように、制御依存を尊重します。ただし、ARM ISB 命令の代わりに、Power isync 命令を使います。
POWER アーキテクチャの多くのメンバーは、インコヒーレントな命令キャッシュを持ちます。なので、メモリへのストアは命令キャッシュに反映されないこともあります。ありがたいことに、この頃は、自己変更コードを書く人は少ないです。しかし、JIT とコンパイラはそれをいつでもやっています。さらに、最近走ったプログラムをリコンパイルするのは、CPUの観点からは、ちょうど自己変更コードのように見えます。icbi (instruction cache block invalidate) 命令は、指定されたキャッシュラインを命令キャッシュから無効化します。これらの状況で使うことができます。
C.7.7 SPARC RMO, PSO, and TSO
SPARC 上の Solaris は、“sparc” 32ビットアーキテクチャのためにビルドされたLinuxと同じく、TSO (total-store order) を使います。しかし、64ビットLinuxカーネル(“sparc64”アーキテクチャ)は、SPARC を、RMO (relaxed-memory order) モード [SPA94] で実行します。SPARC アーキテクチャはさらに、中間的な PSO (partial store order) も提供します。RMO で走る全てのプログラムは、PSO あるいは TSO のどちらでも走るでしょう。同様に、PSO で走る全てのプログラムは、 TSO でも走るでしょう。共用メモリ並列プログラムを逆の方向に移動するのは、注意深くメモリバリアを挿入する必要があるかもしれません。しかし、以前にも述べたように、同期プリミティブを標準的に使っているプログラムは、メモリバリアの心配をする必要はありません。
SPARC は、細粒度のオーダリングの制御を可能とする、とても柔軟なメモリバリア命令を持ちます [SPA94]。
• StoreStore: 以前のストアを後のストアの前にオーダーします。 (このオプションは Linux smp_wmb() プリミティブで使われます)
• LoadStore: 以前のロードを後のストアの前にオーダーします。
• StoreLoad: 以前のストアを後のロードの前にオーダーします。
• LoadLoad: 以前のロードを後のロードの前にオーダーします。 (このオプションは Linux smp_rmb() プリミティブで使われます)
• Sync: 以降の操作を始める前に、以前の全ての操作を完了します。
• MemIssue: 以降のメモリ操作の前に、以前のメモリ操作を完了します。ある種のメモリマップトI/O に重要です。
• Lookaside: MemIssue と同じですが、以前のストアと以降のロードにだけ適用されます。さらに、同じメモリ位置をアクセスするストアとロードだけです。
Linux smp_mb() プリミティブは、最初の4つのオプションを全部合わせて使います。 membar #LoadLoad | #LoadStore | #StoreStore | #StoreLoad このようにして、メモリ操作を完全にオーダーします。
では、なぜ、membar #MemIssue が必要でしょう?なぜならば、membar #StoreLoad は以降のロードが値をライトバッファから得ることを許します。それはもしそのライトがMMIOレジスタで、読まれる値に副作用を起こすものだった場合には破壊的です。それに対して、membar #MemIssue は、ロードの実行を許す前に、そのライトバッファがフラッシュされるまで待ちますから、ロードが実際にその値をMMIOレジスタから得ることを保証します。ドライバはその代わりに、membar #Sync を使っても良いですが、membar #Sync の余分で高価な機能が不要であるときには、軽い membar #MemIssue の方が望ましいです。
membar #Lookaside は、membar #MemIssue のより軽いバージョンです。それは、あるMMIOレジスタへのライトが、次にそのレジスタから読まれる値に影響する時には、便利です。しかし、あるMMIOレジスタへのライトが、次にどれか他のレジスタから読まれる値に影響する時には、より重い membar #MemIssue を使わなくてはいけません。
SPARC がなぜ wmb() を membar #MemIssue に、smb_wmb() を membar #StoreStore に定義しないのかはよくわかりません。現在の定義は、ドライバによってはバグのおそれがあるからです。Linux が走る全ての SPARC CPU はアーキテクチャが許すよりもずっと保守的なメモリオーダリングモデルを実装しているというのは大いに有り得るでしょう。
SPARC は、命令がストアされてから、実行されるまでの間に flush 命令が実行されることを要求します [SPA94]。これは、 SPARC の命令キャッシュから指定した位置の全ての以前の値をフラッシュするために必要です。SMP システムでは、全てのCPUのキャッシュがフラッシュされます。しかし、CPUを離れたフラッシュがいつ完了するかを決める簡単な方法はありません。実装ノートに参照はありますけれど。
C.7.8 x86
x86 CPU は、「プロセスオーダリング」を提供するので、すべてのCPUにとって、あるCPUがメモリに書いた順序は同じに見えます。なので、このCPUではsmp_wmb() プリミティブは no-op です。しかし、コンパイラが最適化の結果、smp_wmb() プリミティブを越えてリオーダリングをしないようにコンパイラディレクティブは必要です。
その一方、x86 CPU は、歴史的に、ロードについてはいかなるオーダリングの保証もしてきませんでした。このため、smp_mb() と smp_rmb() プリミティブは、 lock;addl に展開されます。このアトミック命令が、ロードとストアに対してバリアとしてはたらきます。
インテルは、x86 のメモリモデルについても公開しています。それを見ると、インテルの実際のCPUは、以前の仕様書で述べられていたよりもより強いオーダリングを強制していることがわかりました。なので、このモデルは、実際のところ、これまでのデファクトの振る舞いを単純に正式と認めたといってもよいです。さらに最近、インテルは x86 のメモリモデルの更新版を公開しました。それによると、ストアについてはトータルグローバルオーダーが必要とされます。なお、それぞれのCPUはトータルグローバルオーダーが示すよりも先に、自分自身のストアが起きたかのように見るのを許されています。このトータルオーダリングへの例外は、ストアバッファを含む重要なハードウェア最適化を許すために必要です。さらに、メモリオーダリングは因果律を守ります。なので、もしCPU0がCPU1のストアを見たなら、CPU0は、CPU1がそのストアの前に見たすべてのストアを見ることが保証されます。ソフトウェアはこれらのハードウェア最適化の振る舞いを変えるためにアトミック操作を使うことができます。それが、アトミック操作が、対応するアトミックでない操作に比べてより高価であることが多い1つの理由です。このトータルストアオーダーは古いプロセッサーでは保証されていませんでした。
あるメモリ位置に対してはたらくアトミック命令は、すべて同じサイズでなくてはいけないということにも注意が必要です。例えば、1つのCPUがバイトをアトミックに加算し、他のCPUが同じ位置に4バイトのアトミック加算をしたら、後の責任は全部あなたのものです。
また、ある SSE 命令(clflush と non-temporal move 命令)は弱いオーダーであることにも注意下さい。SSE を持つCPUは mfence を smp_mb() に、 lfence を smp_rmb() に、そして sfence を smp_wmb() に使うことができます。
x86 CPU のいくつかのバージョンでは、アウトオブオーダーストアを可能とするモードビットがあります。こういう CPU では、smp_wmb() も lock;addl として定義されなければいけません。
新しい x86 実装は、自己変更コードを特別な命令なしに許容しますが、過去と将来可能性のある x86 実装と完全互換であるためには、CPUは、コードを変更してから実行するまでの間に、ジャンプ命令あるいはシリアライズ命令(例えば cpuid )を実行しなければいけません。
C.7.9 zSeries
zSeries マシンは、以前には 360, 370, そして 390 [Int04c] と呼ばれた IBM™ のメインフレームファミリを構成します。並列性が zSeries に訪れたのは最近です。しかしこれらのメインフレームが最初に出荷されたのは1960年代なかばであることを思うと、それは驚くべきことではありません。Linux の smp_mb(), smp_rmb(),そして smp_wmb() プリミティブには、bcr 15,0 命令が使われます。それはまた、表C.5に示すように、比較的強いメモリオーダリングセマンティクスを持ちます。そのため、smp_wmb() プリミティブは nop であることが可能です(そして、あなたがこれを読んでいる時には、その変更が実際にされているかもしれません)。その表は実際にその状況を理解しています。zSeries メモリモデルは、それ以外はシーケンシャルにコンシステントです。ということは、全てのCPUは異なるCPUからの無関係なストアの順序について合意することを意味します。
ほとんどのCPUと同じく、zSeries アーキテクチャはキャッシュコヒーレントな命令ストリームを保証しません。なので、自己変更コードは、命令の変更と実行の間に直列化命令を実行しないといけません。とは言え、多くの実在のzSeries は、直列化命令なしでも実際には自己変更コードを受け入れます。
zSeries 命令セットは直列化の大きなセットを提供します。それには、コンペアアンドスワップ、ある種の分岐(例えば、前記の bcr 15,0 命令)、そしてテストアンドセットがあります。
C.8 メモリバリアは永遠ですか?
最近のシステムのいくつかには、アウトオブオーダー実行について一般的に、メモリ参照のリオーダリングについては特に、全くアグレッシブでないものがあります。このトレンドが続いて、メモリバリアが過去のものとなる時が来るのでしょうか?
それに賛成する意見は、提案されているマッシブにマルチスレッド化されたハードウェアアーキテクチャをあげるでしょう。そこでは、それぞれのスレッドがメモリが使えるまで待ちます。その間に、何十、何百、時には何千もの他のスレッドが仕事を続けています。そのようなアーキテクチャのもとでは、メモリバリアは不要です。なぜならばあるスレッドは次の命令に進む前に、単純に全ての実行中の操作が完了するのを待てば良いです。潜在的に他のスレッドは何千もありますから、CPUは完全に活用され、ムダになるCPU時間は無いでしょう。
それに反対する意見は、千のスレッドにまでスケールすることのできるアプリケーションの数は極めて限られることをあげるでしょう。または、アプリケーションによっては、数十マイクロ秒以内を要求するリアルタイム要件がますます厳しくなってきたこともあげられます。そのリアルタイム応答の要件はそれだけでも満足するのは十分に難しいですが、そのようなマッシブマルチスレッド化したシナリオが意味する極端に低いシングルスレッドのスループットを考えると、より一層困難でしょう。
もう一つの賛成意見は、ますます洗練されてきた、遅延を隠すハードウェア実装テクニックをあげるでしょう。それは、CPUが完全にシーケンシャルにコンシステントな実行という幻想を提供する一方、アウトオブオーダー実行の性能長所のほとんどを提供することを可能とするでしょう。反対意見は、電池で駆動するデバイスと環境責任の両方が要求する、ますますきびしい省電力要求をあげるでしょう。
誰が正しいでしょう?私達は鍵は持っていません。なので、どちらのシナリオとも共存する準備をします。
C.9 ハードウエア設計者への助言 ハードウエア設計者はいろいろな手を使ってソフトウェアの人々の人生を困難にすることができます。以下は、私達がこれまでに出くわしたそういったもののいくつかです。今後こういった問題が繰り返されることを防ぐのに役に立つことを祈って記します。 1 キャッシュコヒーレンスを無視するI/Oデバイス この魅力的に誤った機能は、メモリから出力バッファへのDMAが最新の変更を見落とすことになったり、同じように困ったことに、DMAが完了した後で入力バッファがCPUキャッシュの内容で上書きされたりすることになります。こんなことがあってもシステムを動かすためには、DMAバッファをI/Oデバイスに渡す前に、そのバッファのすべての位置のCPUキャッシュを注意深くフラッシュしなくてはいけません。同様に、DMAバッファへのDMAがすべて終わったら、そのバッファのすべての位置のCPUキャッシュをフラッシュしなくてはいけません。そして、それでもなおポインターのバグを避けるようにとても注意しないといけません。入力バッファへの誤った位置へのリードはデータ入力をこわすことになりますから! 2 キャッシュコヒーレントなデータを転送できない外部バス これは、前記の問題のより苦痛に満ちた変種です。デバイスのグループ、時には、メモリ自身も、キャッシュコヒーレンスを守ることができなくします。組み込みシステムがマルチコアアーキテクチャになってくるにつれて、疑いなく、この問題が多く現れるだろうことを、あなたに伝えるのは私のつらい義務です。願わくば、これらの問題が、2015年までに片付くことを。 3 キャッシュコヒーレンスを無視するデバイス割り込み これは、無邪気に聞こえるかもしれません。結局、割り込みはメモリ参照ではないですよね?しかし、CPUがスプリットキャッシュを持っていて、その1つのバンクはとても忙しいために、入力バッファの最後のキャッシュラインを持ったままだとします。対応するI/O完了割り込みがこのCPUに来た時、このCPUのバッファの最後のキャッシュラインへのメモリ参照は古いデータを返すかもしれず、また、データ破壊につながります。しかし、その後のクラッシュダンプではわからない形でです。システムが不正な入力バッファをダンプしようとするときには、ほとんどの場合、DMAは終わっているでしょうから。 4 キャッシュコヒーレンスを無視するプロセッサ間割り込み(IPI) IPIが宛先についた時に、対応するメッセージバッファのすべてのキャッシュラインがメモリにコミットされていないと面倒です。 5 キャッシュコヒーレンスの先を行くコンテキストスイッチ メモリアクセスが非常にワイルドにアウトオブオーダーに完了すると、コンテキストスイッチも困ったことになります。タスクがあるCPUから別のに移動するときに、移動元CPUに見えるメモリアクセスがすべて、移動先CPUにも見えるようになるのが遅れると、そのタスクは対応する変数が以前の値に戻るのを見ることになり、ほとんどのアルゴリズムを致命的に混乱させるでしょう。 6 親切すぎるシミュレータとエミュレータ メモリのリオーダリングを強制するシミュレータやエミュレータを書くのは難しいので、これらの環境でうまく動いていたソフトウェアを最初に実際のハードウェアで動かすと、思ってもみないやっかいな事になることがあります。不幸なことに、ハードウェアはシミュレータやエミュレータよりもひねくれているのは現在の規則です。でも、この状況が変わるのを期待します。 もう一度繰り返します。ハードウエア設計者はこれらの例を避けるようにおすすめします!
以上