拙訳
疑問(Questions)
- キャッシュはどのようにしてコア間のデータを共有するのか?
- 複数コアが同時にメモリを更新しようとするとき、どのようにしてデータの整合性を保つのか?
単純な2コアCPU(Simple 2-core CPU)
(画像)
キャッシング(Caching)
(画像)
- データはキャッシュラインに格納される(64/128バイト)
- 最近使われたキャッシュラインには高速にアクセスできる
メモリは遠く離れている(Memory Is Far Away)
(画像:PS3のアーキテクチャ)
ICB --- インターコネクトバス(ICB - Inter Connect Bus)
- コアを接続する
- データだけじゃない
- キャッシュコヒーレンスなプロトコル
- “キャッシュコヒーレンスなドメイン”
- 通常はすべてのプロセッサとすべてのコア
MESIプロトコル(MESI Protocol)
- キャッシュコヒーレンス
- どのキャッシュラインでも一度に変更できるのは1つのコアだけ
- キャッシュラインは4つの状態を取ることができる1
- M 変更
- そのキャッシュだけに存在し、メインメモリ上の値から変更されている
- E 排他
- そのキャッシュだけに存在し、メインメモリ上の値と一致している(コピーを持つ)
- S 共有
- 他のキャッシュにも存在し、すべてがメインメモリ上の値と一致している(コピーを持つ)
- I 無効
- キャッシュラインが古くなり、もはや正しくない
- M 変更
MESIプロトコルのメッセージ(MESI Protocol Message)
- メッセージはキャッシュ間のコヒーレンシーを維持するためにICBで送られる
- ICB上の誰もが’読み込み’メッセージに応答することができる
- メモリコントローラだけでなく他のコアも
MESIのメッセージタイプ(MESI Message Types)
- メッセージタイプ(キャッシュラインに関連するもの)
- 読み込み / 読み込み確認
- RWITW --- 書き込み目的の読み込み
- 読み込み+無効化
- 無効化 / 無効化確認
- このキャッシュラインを無効化するよう他のコアに依頼する
- 書き戻し
- キャッシュラインをメインメモリに書き戻す
キャッシュライン遷移(Cache line transitions)
- キャッシュラインを読み込む
- 無効 -> 排他
- ひとつのコアだけがコピーを持つ場合
- 無効 -> 共有
- 他のコアもコピーを持つ場合
- 無効 -> 排他
- キャッシュラインに書き込む
- 排他 -> 変更
- 共有 -> 変更
- 他のすべてのコアはそのコアが持つこのキャッシュラインを無効化する
- 無効化するよう指示する
- 排他 / 共有 -> 無効
- 変更 -> 無効
- メインメモリへの’書き戻し’を誘発する
- 別のコアが変更済みキャッシュラインを読み込もうとする
- 変更 -> 共有
- メインメモリへの’書き戻し’を誘発する
- 変更 -> 共有
選手入場(The Players…)
- 例
- コア0 --- プロデューサー
void foo() {data = 1; flag = 1;}
- コア1 --- コンシューマー
void bar() {while (flag == 0); assert(data);}
- コア0 --- プロデューサー
キャッシュ所有権の例(Cache Ownership Example)
if (a) {
b = 4;
}- 前提として、
- 初期値は
a = 1、b = 0とする aとbは別のキャッシュライン上にあるとする- コア0のキャッシュは空である
- コア1のキャッシュは
aとbを”排他”状態で持つとする
- 初期値は
- コア0が
if (a)を評価しようとするとき、- コア0は自身のキャッシュに
aを持っていないので、aを要求するために’読み込み(a)‘メッセージを送る - コア1は要求に応答して’読み込み確認(a = 1)‘メッセージを送り、
aを”共有”状態にする - コア0は応答を受け取って自身のキャッシュにインストールする。これで、コア0は’a’による分岐を評価できるようになる
- コア0は自身のキャッシュに
- コア0が
b = 4を評価しようとするとき、- コア0は自身のキャッシュに
bを持っていないので、bを要求するために’RWITW(b)‘メッセージを送る- 今回は読み込み後に書き込む必要があるので、その意向を示すために’RWITW(書き込み目的の読み込み)‘を送る
- コア1は要求に応答して’RWITW(b = 0)‘メッセージを送り、
bを”無効”状態にする- ‘RWITW’メッセージは’無効化’の要求でもある
- コア0は応答を受け取って自身のキャッシュに”排他”状態でインストールする。これで、コア0は
bを単独で持っていることになる - コア0は
bに4を格納して”変更”状態にする。このときはまだメインメモリに書き戻していない
- コア0は自身のキャッシュに
2コアCPU+StoreQ(2-core CPU + Store Qs)
(画像)
StoreQの理由(Reasons for Store Q)
- 不足分の/無効なキャッシュラインを待っている間のCPU実行ストールを軽減する
- すると、キャッシュラインが他より先に使える状態であるなら、ロードはストアを”追い越す”ことができる
- すでにローカルキャッシュで、または、隣接するコアによって使えるようになっているかもしれない
- ローカルで動作しているコアに対してメモリが同じに見えることを保証するため、ロードに対するStoreQのスヌーピング(覗き見)が必要
- ストアがキャッシュにたどり着かなかったとしても、後続のロードは格納された値をロードするべき
StoreQ問題の例(Store Q Issue Example)
void foo() {
data = 1;
flag = 1;
}: コア0
void bar() {
while (flag == 0);
assert(data);
}: コア1
- 前提として、
- コア0は
fooを実行する - コア1は
barを実行する flagのキャッシュラインはコア0が所有するdataのキャッシュラインはコア1が所有する
- コア0は
- コア0が
data = 1;を評価するとき、- コア0はストア命令をStoreQに保存する。
dataがキャッシュに存在しないので、dataの’RWITW’メッセージを送る
- コア0はストア命令をStoreQに保存する。
- コア1が
while (flag == 0);を評価するとき、- コア1は、
flagがキャッシュに存在しないので、flagの’読み込み’メッセージを送る
- コア1は、
- コア0が
flag = 1;を評価するとき、- コア0は
flagを所有しているので、キャッシュを1に更新して”変更”状態にする - コア0は
flagの読み込み要求に応答して’読み込み確認(flag = 1)‘を送り、flagを”共有”状態にしてメインメモリに書き戻す - コア1は応答を受け取って自身のキャッシュに”共有”状態でインストールする
- コア0は
- コア1が
assert(data);を評価するとき、dataは”排他”状態で持っているので、そのまま読み込まれる。アサート!!data = 1;はここまでコア0のStoreQに保存されたままである
- コア1は遅れてきたRWITW要求に応答して’RWITW(data = 0)‘を送り、
dataを”無効”状態にする - コア0は応答を受け取り、自身のキャッシュに”排他”状態でインストールする
- コア0のStoreQは
flagへの書き込みをコミットできるが、時すでに遅し。コア1は停止し、実行がストップする
どうやってこの問題を解決するか?(How do we solve this issue?)
- すべてのキャッシュはメインメモリの一貫性のある視点を持つが、ローカルな書き込みはその範疇ではない
- 格納されるデータは’キャッシュコヒーレントな領域’の一部であることを保証する方法が必要
- すなわち、他のコアによって見えている
- すなわち、他のコアがフェッチできる
- すなわち、他のコアによって見えている
- StoreQをキャッシュにフラッシュできる?
- メモリストアバリア(
__mb_release)
- メモリストアバリア(
メモリストアバリア(Memory Store Barirers)
- メモリバリアより先行しているStoreQ中のすべてのデータがキャッシュ中に存在するようになるまで返らないCPU命令
- CPUは悪!
- コンパイラがこのバリアをまたいでメモリストアを最適化するのを防ぐ
- コンパイラは悪!
- そのキャッシュ中にデータが存在するようになれば、他のすべてのキャッシュでキャッシュラインが無効化されるため、他のすべてのキャッシュによって確認できるようになる
- RWITW(書き込み目的の読み込み)
- 読み込み+無効化
- RWITW(書き込み目的の読み込み)
StoreQ問題の例(修正版)
void foo() {
data = 1;
__mb_release();
flag = 1;
}: コア0
void bar() {
while (flag == 0);
assert(data);
}: コア1
- 前提として、
- コア0は
fooを実行する - コア1は
barを実行する flagのキャッシュラインはコア0が所有するdataのキャッシュラインはコア1が所有する
- コア0は
- コア0が
data = 1;を評価するとき、- コア0は書き込み命令をStoreQに保存する。その後、
dataがキャッシュに存在しないので、dataの’RWITW’メッセージを送る
- コア0は書き込み命令をStoreQに保存する。その後、
- コア1が
while (flag == 0);を評価するとき、- コア1は、
flagがキャッシュに存在しないので、flagの’読み込み’メッセージを送る
- コア1は、
- コア0が
__mb_release();を評価するとき、- コア0はキャッシュをフラッシュするためにStoreQに対するメモリバリアでブロックする
- コア0は
flagの読み込み要求を受け取って’読み込み確認(flag = 0)‘応答を送り、flagを”共有”状態にする - コア1は読み込み応答を受け取り、自身のキャッシュに”共有”状態でインストールする
- コア1は遅れてきたRWITW要求に応答して’RWITW(data = 0)‘を送り、
dataを”無効”状態にする - コア0は応答を受け取り、自身のキャッシュに”排他”状態でインストールする
- コア0はStoreQの
dataへの書き込みをキャッシュにコミットして、dataを”変更”状態にする
- コア0が
flag = 1;を評価するとき、- コア0は
flagに1をセットしたいがflagは”共有”状態なので、まずflagの無効化要求メッセージを送る - コア1は無効化要求を受け取って’無効化確認(flag)‘を送り、
flagを”無効”状態にする - コア0は応答を受け取ると
flagを変更することができるようになる
- コア0は
- コア1が
while (flag == 0);を再び評価するとき、- コア1は
flagが”無効”状態なのでflagの’読み込み’メッセージを送る - コア0は
flagの読み込要求に応答して’読み込み確認(flag = 1)‘を送り、flagをメインメモリに書き戻して”共有”状態にする - コア1は応答を受け取り、自身のキャッシュに
flagを”共有”状態でインストールする。これでwhile (flag == 0)のループから抜け出すことができる
- コア1は
- コア1が
assert(data);を評価するとき、- コア1は同様にして
dataを読み込む
- コア1は同様にして
2コアCPU+StoreQ+InvQ(2-core CPU + Store Qs + Inv Q)
(画像)
InvalidateQの理由(Reasons for Invalidate Q)
- 他のコアからのより高速な無効化応答
- ビジーなコアは応答するのに時間がかかる可能性がある
- ‘無効化確認’応答はキャッシュがキャッシュラインを実際に無効化するまで送ることができない
- 契約: キューに入れたメッセージで、そのキャッシュラインに対するものすべてが処理され終わるまで、そのキャッシュラインに対するMESIメッセージはこのコアによって送られることはない
InvalidateQ問題の例(Invalidate Q Issue Example)
- 前提として、
- コア0は
fooを実行する - コア1は
barを実行する flagのキャッシュラインはコア0が所有するdataのキャッシュラインはコア1が所有する
- コア0は
- コア0が
data = 1;を評価するとき、- コア0は書き込み命令をStoreQに保存する。その後、
dataがキャッシュに存在しないので、dataの’RWITW’メッセージを送る
- コア0は書き込み命令をStoreQに保存する。その後、
- コア1が
while (flag == 0);を評価するとき、- コア1は、
flagがキャッシュに存在しないので、flagの’読み込み’メッセージを送る
- コア1は、
- コア0が
__mb_release();を評価するとき、- コア0はキャッシュをフラッシュするためにStoreQに対するメモリバリアでブロックする
- コア1はRWITW要求に応答して’RWITW(data = 0)‘を送り、
dataの無効化をInvQに記録する - コア0は応答を受け取り、自身のキャッシュに”排他”状態でインストールする
- コア0はStoreQの
dataへの書き込みをキャッシュにコミットして、dataを”変更”状態にする
- コア0が
flag = 1;を評価するとき、- コア0は、
flagが”排他”状態であるため、flagを直接更新する - コア0は
flagの読み込み要求を受け取って’読み込み確認(flag = 0)‘応答を送り、flagをメインメモリに書き戻して”共有”状態にする - コア1は読み込み応答を受け取り、自身のキャッシュに”共有”状態でインストールする
- コア0は、
- コア1が
assert(data);を評価するとき、- コア1は、
dataが”排他”状態であるため、メッセージは送らずに自身の古なった値を読み込むdataの無効化処理はInvQに保存されたままである
- コア1はInvQから
dataの無効化を適用するが遅すぎる。クラッシュ!!
- コア1は、
どうやってこの問題を解決するか?(How do we solve this issue?)
- 今回は、ローカルコアは読み込みを処理するときに持っているすべての情報を使っていない
- なぜ?
- スピード、スピード、スピード
- なぜ?
- すべての情報を使うことをコアに強制する方法はあるのか?
- はい、‘InvalidateQ’をフラッシュできる
- メモリロードバリア(
__mb_acquire)
メモリロードバリア(Memory Load Barriers)
- CPU命令
- InvalidateQ内のすべてのメッセージが処理される
- バリアに先立つすべてのロードが完了する
- CPUは悪だって言ったよな!
- コンパイラがこのバリアをまたいだメモリロードを最適化しないようにさせる
- コンパイラは悪!
- バリア後のデータ読み込みは他のキャッシュ/メインメモリから新しく引き出されることを保証する
- 古くなったキャッシュラインを効果的に立ち退かせる
InvalidateQ問題の例(修正版)
void foo() {
data = 1;
__mb_release();
flag = 1;
}: コア0
void bar() {
while (flag == 0);
__mb_acquire();
assert(data);
}: コア1
- 前提として、
- コア0は
fooを実行する - コア1は
barを実行する flagのキャッシュラインはコア0が所有するdataのキャッシュラインはコア1が所有する
- コア0は
- コア0が
data = 1;を評価するとき、- コア0は書き込み命令をStoreQに保存する。その後、
dataがキャッシュに存在しないので、dataの’RWITW’メッセージを送る
- コア0は書き込み命令をStoreQに保存する。その後、
- コア1が
while (flag == 0);を評価するとき、- コア1は、
flagがキャッシュに存在しないので、flagの’読み込み’メッセージを送る
- コア1は、
- コア0が
__mb_release();を評価するとき、- コア0はキャッシュをフラッシュするためにStoreQに対するメモリバリアでブロックする
- コア1はRWITW要求に応答して’RWITW(data = 0)‘を送り、
dataの無効化をInvQに記録する - コア0は応答を受け取り、自身のキャッシュに”排他”状態でインストールする
- コア0はStoreQの
dataへの書き込みをキャッシュにコミットして、dataを”変更”状態にする
- コア0が
flag = 1;を評価するとき、- コア0は、
flagが”排他”状態であるため、flagを直接更新する - コア0は
flagの読み込み要求を受け取って’読み込み確認(flag = 0)‘応答を送り、flagをメインメモリに書き戻して”共有”状態にする - コア1は読み込み応答を受け取り、自身のキャッシュに”共有”状態でインストールする
- コア0は、
- コア1が
__mb_acquire();を評価するとき、- コア1はメモリバリアに到達し、InvalidateQが空になるまで待機する
- コア1はInvalidateQを処理し、
dataを”無効”状態にする - InvalidateQが空になったので、待機が明け実行を再開する
- コア1が
assert(data);を評価するとき、- コア1は
dataが”無効”状態なのでdataの’読み込み’メッセージを送る - コア0は
dataの読み込要求に応答して’読み込み確認(flag = 1)‘を送り、dataをメインメモリに書き戻して”共有”状態にする - コア1は応答を受け取り、自身のキャッシュに
dataを”共有”状態でインストールする。これでassert(data)を通過できる
- コア1は
解決法(メモリバリア)(The Solutions (Memory Barriers))
- 2種類ある
- 解放セマンティクス
__mb_release- StoreQをフラッシュする
- プロデューサーの挙動
- コンパイラがバリアをまたいでストアを移動できなくする
- 取得セマンティクス
__mb_acquire- キャッシュInvalidateQをフラッシュする
- 先立つロードが完了する
- コンシューマーの挙動
- コンパイラがバリアをまたいでロードを移動できなくする
- 解放セマンティクス
メモリバリア --- 解放セマンティクス(Memory Barriers - Release Semantics)
- コンパイラやCPUによる書き込みの並べ替えを防ぐ
- データへのアクセス権を配るときに使われる
- x86/x64:
_ReadWriteBarrier();、_mm_sfence();- コンパイラ固有命令、コンパイラの並び替えを防ぐ
- PowerPC:
__lwsync();- ハードウェアバリア、CPU書き込みの並び替えを防ぐ
- 位置取りが重要!
- データを書き込む
__mb_release()- 制御値を書き込む
メモリバリア --- 取得セマンティクス(Memory Barriers - Acquire Semantics)
- コンパイラやCPUによる読み込みの並び替えを防ぐ
- データへのアクセス権を得るときに使われる
- x86/x64:
_ReadWriteBarrier();、_mm_lfence();- コンパイラ固有命令、コンパイラの並び替えを防ぐ
- PowerPC:
__lwsync();またはisync();- ハードウェアバリア、CPU読み込みの並び替えを防ぐ
__lwsync();- これはStoreQ、InvQ、ロード待機を同期させる
__isync();- これはInvQを空にして、命令キャッシュをクリアする。これは命令プリフェッチ、すなわち、投機的な読み込みを防ぐ。これはあとで読み込まれるいかなるデータも最新であることを保証する
- 位置取りが重要!
- 制御値を読み込む
__mb_aquire()- データを読み込む
lwsyncについての簡単なメモ(A quick note on lwsync())
- ストア→ロードの順を強制するわけではない
- ‘取得’メモリバリアで気を付ける
| Xbox360の並び替え | 同期なし | lwsync | sync |
|---|---|---|---|
| 読み込みが読み込みの先に移動する | Y | N | N |
| 書き込みが書き込みの先に移動する | Y | N | N |
| 書き込みが読み込みの先に移動する | Y | N | N |
| 読み込みが書き込みの先に移動する | Y | Y | N |
フォールスシェアリング(False Sharing)
- 異なるコアで処理される、同じキャッシュライン上のデータメンバーを持つのを回避する
- これをやらないと、結果としてキャッシュラインが行ったり来たりしたりパフォーマンスが劣化したりすることになる
- これを抑制するために、自身のキャッシュライン上に共有データのアクセス制御フラグを維持する
3つの独立層(Three Independent Layers)
- 実行
- CPUは、キャッシュがそのデータを持つ限り、キャッシュから独立して実行できる
- キャッシュ
- キャッシュはキャッシュコヒーレンシーを保証するためにメインメモリにいかなるデータも書き込む必要はない
- 他の最適化として、本当に必要とされるまでメインメモリへの書き込みを更に抑制する方法が存在する(例: MOESI)
- メインメモリ
- I/Oデバイス(GPUの読み出し)はキャッシュではなくこれを見ている。他のデバイスがデータを読めるようにしたいなら、そのデータをちゃんとメインメモリに最後まで書き込む必要がある
- メインメモリは本当に遠く離れている
コンペア・アンド・スワップ(CAS)(Compare And Swap (CAS))
- 整列したネイティブサイズのメモリロケーションのアトミック更新
- 32ビットや64ビット、時折その他のサイズも
- メインメモリではなく、‘キャッシュレイヤー’で処理する
- PowerPC
- 予約付きロード(アドレス)
- 条件付きストア(アドレス, 新しい値)
- 成功(0)/失敗(0以外)を返す
- Intel/AMD
- CompareAndExchange(アドレス, 期待される値, 新しい値)
- ‘アドレス’内の以前の値を返す
- 以前の値 == 期待される値 ? 成功 : 失敗
- CompareAndExchange(アドレス, 期待される値, 新しい値)
例: コンペア・アンド・スワップ(CAS)(Example: Compare And Swap (CAS))
- PowerPC/PS3:
- lwarx / stwcx
- PowerPCプロセッサは一度にひとつの予約のみを保持できる
- ほかのすべての先立つロード/ストアが他のコアから見えていることを保証しているわけではない
- 解決策: CAS成功後に
__lwsyncか__isyncを追加する
- 解決策: CAS成功後に
- lwarx / stwcx
- Intel/AMD
- lock cmpxchg [ecx], edx
- ecx --- 変数へのポインタ
- eax --- 期待される変数の値
- edx --- 変数 == eaxの場合に書き込む値
- すべての先立つロード/ストアが他のコアから見えていることを保証している
- lock cmpxchg [ecx], edx
取得の例(スピンロック)(Acquire Example (Spinlock))
spinlock_64_try_mp:
mr r5, r3 // r5はロックアドレス
li r3, 1 // '成功'の戻り値をロード
1:
lwarx r4, 0, r5 // 予約付きロード
li r6, -1 // locked == -1
cmpwi r4, 0
bne-- 2f // ロックされていれば、早期離脱する
stwcx. r6, 0, r5 // 条件付きストア
isync // 投機的実行をキャンセルする
beqlr++ // 成功すれば、CASは成功(r3 = 1)を返す
b 1b // そうでなければ、もう一度試す
2:
li r6, -4
stwcx. r5, r6, r1 // 保留中の予約をクリア(ダミー書き込み)
li r3, 0 // ロックを得られず、失敗(r3 = 0)が返る
blr: AppleOSのコード
解放の例(スピンロック)(Release Example (Spinlock))
spinlock_64_unlock_mp:
lwsync // アンロックの前に先立つストアを完了させる
li r4, 0
stw r4, 0(r3)
blrマルチコアプログラミングは難しい(Multi-Core Programming Is Hard)
- 既存のOS同期プリミティブ(クリティカルセクション、ミューテックス、イベント)を使うことは賢明な判断である
- …本当にパフォーマンスが欲しい場合でないならば
- ミューテックスのロック = 数千サイクル
- バリア = 約100サイクル
サイクル計測(Timings in cycles)
| XBox PowerPC | Windows Intel | |
|---|---|---|
| lwsync | 33-48 | 20-90 |
| InterlockedIncrement CAS(OS関数) | 225-260 | 36-90 |
| クリティカルセクション(Acq+Rel) | 約345 | 40-100 |
| ミューテックス(Acq+Rel) | 約2350 | 750-2500 |
スレッドセーフ != コンカレント(Thread-Safe != Concurrent)
ベストプラクティス(Best Practice)
- OSが提供するクリティカルセクションを用いることを推奨する
- 取得/解放メモリバリアを用いる
- 共有するデータ/フラグを128バイト境界に整列させる
- ハードウェアプリフェッチによりIntel/AMDでも
- PS3: 疑わしいときや、ロック後にデータを読み込むときは、
lwsyncより前にisyncを使う - チェックインする前にコードが機能していることを保証するため、コードがこのレベルで書かれたいかなるコードも同僚たちにレビューさせる
おみやげ(Take Away)
- 他のレイヤーへのデータを強制するための固有の命令を使う
- キャッシュレイヤー(他のコア) ---
lwsyncとisync - メインメモリ(IOデバイス) --- eioioと
sync
- キャッシュレイヤー(他のコア) ---
- SPUプログラミングやDMAのやつに少し似ている
- レイヤー間でデータを明示的に’転送’する
- これはプロセッサの動作方法を非常に単純化して見ている
- 例: メモリアクセスモード
- Write-Through
- Cache-Inhibited
- Cache-Coherent
- Guarded
- 例: メモリアクセスモード
- 恐れよ!!
- 恐ろしくないとしたら、このプレゼンテーションを理解していないということだ