偽共有とキャッシュコヒーレンシ(MESIプロトコル)
並行処理が並列化したのに遅いとき、犯人は論理的に独立な変数の奪い合い。MESIの動作と偽共有の原理を押さえれば、パディング一発で数倍の差を取り戻せる。
- 1.各コアが持つL1/L2キャッシュの内容を一貫させる仕組みがキャッシュコヒーレンシで、MESIは各ラインをModified/Exclusive/Shared/Invalidの4状態で管理し、書き込み前に他コアのコピーを無効化する。
- 2.偽共有は、論理的に独立な変数がたまたま同一キャッシュライン(典型64バイト)に同居し、片方への書き込みがもう片方を持つコアのラインを無効化して、無用なコヒーレンシトラフィックで性能を落とす現象。
- 3.対策はホットな変数を64バイト境界へパディングで引き離すこと。スレッドごとの書き込み先を別ラインに分けるだけで、スケーラビリティが劇的に改善する。
なぜキャッシュの一貫性が問題になるのか
マルチコアCPUでは、各コアが自分専用のL1・L2キャッシュを持ちます。あるコアが書き換えた値が、別のコアの古いキャッシュに残ったままでは、同じメモリアドレスを読んでもコアごとに違う値が見える——これではプログラムは正しく動きません。そこで全コアのキャッシュ内容を「メモリは1つしかない」かのように見せる仕組みが**キャッシュコヒーレンシ(cache coherency、キャッシュ一貫性)**です。
ここで前提になるのが、CPUがメモリを1バイト単位ではなくキャッシュライン(多くのアーキテクチャで64バイト)単位で扱うことです。詳しくはメモリレイアウトとデータ局所性で扱いましたが、コヒーレンシもこの64バイトのライン単位で管理されます。この「管理の最小単位がライン」という一点が、後述する偽共有という落とし穴を生みます。
MESIプロトコルの4状態
最も基本的なコヒーレンシプロトコルがMESIです。各コアのキャッシュは、保持している各ラインに対して以下の4状態のいずれかを持ちます。状態の頭文字がそのまま名前になっています。
| 状態 | 意味 | 他コアのコピー | メモリとの関係 |
|---|---|---|---|
| Modified | 自分だけが持ち、書き換え済み | 存在しない | メモリは古い(dirty) |
| Exclusive | 自分だけが持ち、未変更 | 存在しない | メモリと一致(clean) |
| Shared | 複数コアが読み取り目的で共有 | 存在しうる | メモリと一致(clean) |
| Invalid | 無効。中身は信用できない | — | 使えない |
要点は2つです。第一に、Modified か Exclusive のラインはそのコアだけが持つため、書き込みが他コアと衝突しません。第二に、Shared は読み取り専用での共有であり、ここに書き込もうとすると状態遷移が必要になります。
書き込みの流れはこうです。あるコアがラインに書こうとすると、まずバス(または専用の相互接続)を通じて他コアへ**無効化要求(invalidate)**を送ります。他コアは自分の同ラインを Invalid に落とし、書き込んだコアだけがそのラインを Modified に遷移させます。これにより「書き込み中のラインは常に1コアの占有」という不変条件が保たれ、一貫性が守られます。逆に言えば、書き込みのたびに他コアのコピーを潰すコストが発生するのです。
MESIには Owned 状態を加えたMOESI(AMDなどが採用)があります。Owned は「自分がdirtyな最新版を持ちつつ、他コアにも Shared として読ませる」状態で、変更後の値を毎回メモリへ書き戻さずにコア間で直接共有でき、帯域を節約します。Intelは Forward 状態を加えたMESIFを用い、Shared の中で「キャッシュ間転送の応答役」を1コアに定めて応答の重複を防ぎます。いずれも基本骨格はMESIです。
偽共有という落とし穴
ここからが本題です。コヒーレンシはライン単位で動くので、ハードウェアは「ライン内のどのバイトが変わったか」までは区別しません。すると、論理的にはまったく独立な2つの変数が、たまたま同じ64バイトのラインに同居していた場合、一方への書き込みがもう一方を持つコアのラインまで丸ごと無効化してしまいます。
// counters[0] をスレッド0が、counters[1] をスレッド1が更新するとする
long counters[2]; // 8バイト×2=同一の64バイトラインに同居しやすい
// スレッド0
for (int i = 0; i < N; i++) counters[0]++;
// スレッド1
for (int i = 0; i < N; i++) counters[1]++;
counters[0] と counters[1] はプログラム上は無関係です。にもかかわらず両者が同じラインに載ると、スレッド0が counters[0] に書くたびにスレッド1のキャッシュ内のラインが Invalid になり、スレッド1は次の counters[1] 更新でわざわざラインを取り直します。そして今度はスレッド1の書き込みがスレッド0のラインを潰す——このラインの所有権がコア間を往復し続ける現象を**偽共有(false sharing)**と呼びます。
データ競合(メモリモデルとhappens-before関係で扱う、結果が未定義になる本物のバグ)とは異なり、偽共有は結果は正しいまま性能だけが静かに落ちる点が厄介です。コア数を増やしたのに速くならない、むしろ遅くなる、という形で表面化します。
counters[0] を atomic にしても偽共有は消えません。アトミック性はライン内のデータ競合を防ぐだけで、ライン全体の所有権往復はそのままだからです。ロックフリー・ウェイトフリーとCAS命令で扱うCASも、内部で書き込み(所有権獲得)を伴うため、隣接データと同ラインなら同じ罰を受けます。むしろアトミック操作は他コアへの可視性を厳格に要求するため、偽共有の影響がより顕著に出ることがあります。
パディングによる緩和
対策の原理は単純で、偽共有しうる変数どうしを別々のキャッシュラインに引き離すことです。間にパディングを挟み、各変数を64バイト境界に揃えます。
// 各カウンタを64バイト境界に整列し、1ラインを占有させる
struct alignas(64) PaddedCounter {
long value;
char pad[64 - sizeof(long)]; // ライン末尾まで埋める
};
PaddedCounter counters[2]; // 各要素が独立したラインに乗る
C++17なら alignas(64)、CやGCC/Clangなら __attribute__((aligned(64)))、Javaなら @Contended(JDK内部)やパディングフィールド、Rustなら #[repr(align(64))] を使います。これで各スレッドの書き込み先が別ラインになり、無効化の連鎖が切れて、コア数に応じてスケールするようになります。1コア時は差が出ませんが、競合が増えるほど効果は劇的です。
偽共有が問題になるのは、複数コアが同じラインに書き込む場合です。全コアが読むだけのデータ(定数テーブルなど)は全員が Shared 状態で平和に共有でき、無効化は起きません。したがってパディングは闇雲に入れず、頻繁に書かれるホットな変数(カウンタ、ロックのフラグ、キューの先頭/末尾ポインタなど)に絞ります。入れすぎはキャッシュ占有量を無駄に増やし、空間局所性をかえって損ないます。
計測と実務での勘所
偽共有はコードを読むだけでは気づきにくく、プロファイラで疑うのが定石です。perf c2c(cache-to-cache、Linux)はどのキャッシュラインがコア間で奪い合われているかを直接可視化でき、Intel VTuneも同様の解析を提供します。HITM(modifiedなラインへのヒット)イベントが多いラインが偽共有の有力容疑者です。
設計段階での予防策も有効です。スレッドごとにローカルなアキュムレータを持たせ、最後に1回だけ集約すれば、ホットループ中はそもそも共有ラインへ書きません。これは並行カウンタの定石で、書き込み競合自体を消す点でパディングより根本的です。ロックやスレッド同期プリミティブを設計する際も、頻繁に触られるロック語と保護対象データを同ラインに置かない配慮が効きます。
試験・面接では次が頻出です。(1) MESIの4状態の意味と、書き込み時に他コアを Invalid にする遷移。(2) 偽共有はライン単位管理が原因で、論理的に独立な変数でも起きること。(3) データ競合(正しさの問題)と偽共有(性能だけの問題)の区別。(4) 対策は64バイトパディングまたはスレッドローカル集約。原理(コヒーレンシの管理単位=ライン)から各結論を導けるようにしておくと応用が利きます。
まとめ
キャッシュコヒーレンシは、各コアのキャッシュを「単一のメモリ」に見せるための仕組みで、MESIはラインを4状態で管理し、書き込み前に他コアのコピーを無効化することで一貫性を保ちます。この管理がライン単位であるため、論理的に独立な変数が同一ラインに同居すると、無効化が無用に連鎖する偽共有が生じ、並列化したはずのコードがスケールしなくなります。本物のデータ競合と違って結果は正しいまま性能だけが落ちるため、計測なしには気づきにくいのが特徴です。対策はホットな変数を64バイト境界へパディングで引き離すか、スレッドローカルに集約して共有書き込み自体を消すこと。アルゴリズムを詰めたら、次はコア間の見えない奪い合いを断つ——これが並行性能設計の仕上げです。
プログラミング Article
偽共有とキャッシュコヒーレンシ(MESIプロトコル)を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
並行処理
比較で見る軸
難易度: advanced / カテゴリ: プログラミング / タグ数: 6
導入後に効く点
偽共有は、論理的に独立な変数がたまたま同一キャッシュライン(典型64バイト)に同居し、片方への書き込みがもう片方を持つコアのラインを無効化して、無用なコヒーレンシトラフィックで性能を落とす現象。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- プログラミング
- タグ数
- 6
判断チェックリスト
- 自社の用途が「並行処理 / キャッシュ」に近いか確認する。
- 強みである「各コアが持つL1/L2キャッシュの内容を一貫させる仕組みがキャッシュコヒーレンシで、MESIは各ラインをModified/Exclusive/Shared/Invalidの4状態で管理し、書き込み前に他コアのコピーを無効化する。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。