false sharingとキャッシュライン設計
並列化したのに遅い原因の多くはfalse sharing。無関係な変数の同一ライン同居を、パディングとアラインメント、per-CPU設計で消し、コア数に比例してスケールさせる勘所をつかめます。
- 1.キャッシュは64バイトのライン単位で一貫性を管理するため、論理的に無関係な変数でも同じラインに同居すると、片方への書き込みが他方のコピーを無効化しコア間でラインがping-pongする。これがfalse sharing。
- 2.対策は競合する変数をライン境界へアラインしパディングで分離すること。C/C++はalignas(64)、Linuxカーネルは____cacheline_aligned、Javaは@Contendedを使う。読み取り専用データはむしろ詰める。
- 3.per-CPU変数は各CPUの領域をライン境界に並べてfalse sharingを構造的に避ける設計。カウンタや統計値はスレッド/CPUごとに分離し、集計時だけ合算するのが定石。
なぜ「無関係な変数」が干渉するのか
マルチコアの一貫性は キャッシュライン単位 で保たれます。x86-64 や多くの ARM でラインは 64バイト で、CPU はバイト単位ではなくこのブロックごとにメモリと往復し、MESI などのプロトコルがラインごとに状態を持ちます。詳しい状態遷移は メモリ階層とキャッシュコヒーレンシ に譲りますが、本稿で要るのは一点だけです。書き込みは、そのラインを排他的に持つコアでしか行えず、書き込んだ瞬間に他コアの同ラインのコピーは無効化(Invalid)される。
ここに落とし穴があります。一貫性はライン単位なので、ハードウェアは そのラインに何個の変数が同居しているか を区別しません。コア0が変数 a を、コア1が変数 b を更新していて、両者がたまたま同じ64バイトラインに載っていると、a への書き込みが b のコピーまで巻き添えで無効化します。論理的には共有していないのに、ラインを共有しているせいで干渉する。これが false sharing(偽共有) です。
struct counters {
long hits; // コア0だけが更新
long misses; // コア1だけが更新
}; // 8+8=16バイト → 同一64バイトラインに同居しがち
ライン ping-pong のコスト
false sharing が起きると何が遅いのか。コア0が hits を書くたびにラインは「コア0が排他保有する Modified」になり、コア1の同ラインは Invalid へ落ちます。次にコア1が misses を読み書きするとミスし、ラインを取り直す。今度はコア1が排他保有し、コア0のコピーが無効化される。互いに相手のラインを無効化し合い、1本のラインがコア間を往復し続ける ping-pong が発生します。
コア0: store hits → ラインをM(排他)で奪取、コア1のコピーをI化
コア1: store misses → ミス。ラインを奪い返し、コア0のコピーをI化
コア0: store hits → またミス。奪い返す …(延々と往復)
無効化と再取得はコア間あるいはソケット間の相互接続を経るため、L1 ヒットの数十倍から、ソケットをまたぐと数百サイクル級に膨らみます。マルチソケットでは遠ノードへの転送になり、NUMAとメモリアクセス局所性 の非対称コストがそのまま乗ります。
false sharing は 結果を間違えません。一貫性プロトコルが正しく動くからこそ無効化が走るだけで、値は常に正しい。だから単体テストもロジック検証も通り、コアを増やしても速くならない/むしろ遅くなる という形でしか表面化しません。原子的カウンタの配列や、スレッドごとの統計値を密に並べた構造が典型の踏み台です。プロファイラで HITM(Modified ラインへのヒット)や cache-to-cache 転送が突出していたら、まず false sharing を疑います。
対策:アラインメントとパディングで分離する
原理から対策は一つに決まります。書き込みが競合する変数を、別々のキャッシュラインへ追い出す。やり方は、各変数をライン境界に アライン し、後ろに パディング を詰めてラインを占有させることです。
struct counters {
long hits;
char pad[56]; // 64 - 8 = 56バイト詰めてラインを使い切る
long misses; // 次の64バイト境界から始まり、別ラインに乗る
};
ただし手動パディングは保守が難しいので、実務では言語やカーネルの機能を使います。
| 環境 | 手段 | 補足 |
|---|---|---|
| C11/C++11 | alignas(64) / _Alignas | 変数や構造体をライン境界へ。C++17の std::hardware_destructive_interference_size で境界値を取得 |
| Linuxカーネル | ____cacheline_aligned / ____cacheline_aligned_in_smp | L1_CACHE_BYTES に合わせて配置。SMPビルドでのみ効く後者が定番 |
| Java | @Contended(JEP 142) | JDK内部で利用。アプリ利用は -XX:-RestrictContended が必要 |
| 手動 | パディング配列 | 境界値をハードコードしがちで移植性に難。最終手段 |
注意すべきは 両側の保護 です。hits の後ろに詰めても、hits の 手前 に別のホットな変数があれば同じラインに巻き込まれます。確実を期すなら、対象変数の前後をパディングで挟むか、構造体全体をライン境界にアラインしたうえで ____cacheline_aligned 済みの要素を並べます。
パディングは無条件の善ではありません。各変数を1ライン専有にすると 構造体が肥大化 し、L1/L2 に載る件数が減ってキャッシュ容量を圧迫します。分離すべきは「異なるコアから書き込みが競合する」変数だけ。逆に、読み取り専用で共有されるデータや、常に同じコアからまとめて触るデータは、むしろ詰めて同一ラインに収める ほうが空間的局所性が効いて速くなります。対策は「ホットな書き込みの衝突点」をピンポイントで剥がす作業です。
per-CPU変数:構造で false sharing を起こさせない
個別の変数を後追いで剥がすより、最初から競合しないデータ構造 にするほうが堅牢です。その代表が per-CPU変数 で、Linux カーネルが多用します。考え方は単純で、共有カウンタを1つ持って奪い合う代わりに、CPU の数だけ独立した複製を用意し、各 CPU は自分の複製だけを触る。
共有1個: [counter] ← 全CPUが奪い合い→ping-pong
per-CPU: [cpu0][cpu1][cpu2]… ← 各CPUは自分の枠だけ更新→競合なし
↑ 各枠をライン境界に整列して隣同士の干渉も断つ
肝は 各 CPU の枠をキャッシュライン境界に整列して配置 する点です。枠が64バイト未満だと隣の CPU の枠と同居して再び false sharing が起きるため、per-CPU 領域はラインアラインを前提に確保されます。更新時は無効化通信が一切走らず L1 ヒットで完結し、読み出し(集計)が要るときだけ全 CPU の枠を合算 します。書き込みは局所・読み出しは集約、という非対称を活かす設計です。
この発想はユーザー空間でも有効です。スレッドごとの統計はスレッドローカルに持って終了時に合算する、ワークキューはスレッドごとに分けて偶にスチールする、といった「まず分割し、合流は最小限に」の形が、ロック競合と false sharing を同時に減らします。ロックそのものを避ける手法は カーネルのロックフリー同期とCAS を併読すると、競合点を消す設計の引き出しが増えます。
atomic やロックフリーにすれば競合が消える、というのは誤解です。原子的命令も実体は対象ラインを排他取得する操作なので、別変数が同じラインに同居していれば無効化の巻き添えは起きます。ロックフリー化と false sharing 対策は 独立した別作業 で、両方やって初めてスケールします。
設計チェックリスト
- 書き込み主体で分類する:どの変数を、どのコア/スレッドが更新するかを洗い出す。同じ主体が触る塊は詰めてよい。
- 競合点を剥がす:異なる主体が高頻度で書く変数同士を、ライン境界で分離する(alignas /
____cacheline_aligned)。 - 複製を検討する:奪い合う共有カウンタは per-CPU/スレッドローカルへ複製し、集計時のみ合算する。
- 読み取り専用は詰める:不変な共有データは1ラインに密に置き、局所性を稼ぐ。
- 計測で確かめる:HITM・cache-to-cache 転送・スケール曲線で効果を検証する。憶測で詰めすぎない。
「false sharing の原因は無関係変数の同一ライン同居で、正しさは保たれ性能(スケール)だけが落ちる」「対策はライン境界アライン+パディングだが、対象は書き込みが競合する変数に限り、読み取り専用は逆に詰める」「per-CPU変数は複製+ラインアラインで構造的に競合を避け、更新は局所・集計は合算」の3点が頻出です。atomic 化は false sharing を消さない、も狙われます。
まとめ
一貫性が キャッシュライン(典型64バイト)単位 で保たれる以上、論理的に無関係でも同じラインに同居する変数は1つの単位として扱われます。異なるコアがそれぞれを書き込むと相互無効化で ライン ping-pong が起き、正しさは保ったままスケールだけが崩れる のが false sharing です。対策は競合変数を ライン境界へアラインしてパディングで分離 すること(alignas(64) / ____cacheline_aligned / @Contended)。ただし剥がすのは書き込みが衝突する変数に限り、読み取り専用データはむしろ詰めます。さらに堅牢なのは per-CPU/スレッドローカルで複製し、更新を局所化して集計時だけ合算 する設計です。背景の状態遷移は メモリ階層とキャッシュコヒーレンシ、ソケットをまたぐコストは NUMAとメモリアクセス局所性、命令の並べ替えとの違いは メモリオーダリングとメモリバリア も合わせてどうぞ。
OS Article
false sharingとキャッシュライン設計を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
false sharing
比較で見る軸
難易度: advanced / カテゴリ: OS / タグ数: 6
導入後に効く点
対策は競合する変数をライン境界へアラインしパディングで分離すること。C/C++はalignas(64)、Linuxカーネルは____cacheline_aligned、Javaは@Contendedを使う。読み取り専用データはむしろ詰める。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- OS
- タグ数
- 6
判断チェックリスト
- 自社の用途が「false sharing / キャッシュライン」に近いか確認する。
- 強みである「キャッシュは64バイトのライン単位で一貫性を管理するため、論理的に無関係な変数でも同じラインに同居すると、片方への書き込みが他方のコピーを無効化しコア間でラインがping-pongする。これがfalse sharing。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。