歴史
現代のCPUは、異なるコアのプライベートL1キャッシュ間でデータを同期するためにMESIのようなキャッシュコヒーレンシープロトコルを使用しています。独立したスレッドが偶然同じキャッシュライン(通常は64バイトまたは128バイト)に存在する異なるメモリ位置に書き込むと、ハードウェアはこれらの操作を直列化し、キャッシュラインの所有権を継続的に無効化して転送します。この現象はフォールスシェアリングと呼ばれます。C++17では、std::hardware_destructive_interference_sizeが導入され、アーキテクチャのキャッシュライン幅を明らかにし、開発者が可変データを分離できるようにしました。これにより、各スレッドのホット変数が異なるラインを占有し、この同期オーバーヘッドを回避します。
問題
自動ストレージ期間を持つ変数に**alignas(std::hardware_destructive_interference_size)**を適用すると、そのオブジェクトの開始アドレスが特定のスレッドのスタックフレーム内でキャッシュラインサイズの倍数になることが保証されます。しかし、このアラインメントはスレッドのメモリビューに限定されており、物理キャッシュラインの排他的な占有を保証しません。オブジェクトがキャッシュラインより小さい場合、同じスタック内の隣接変数や異なるスレッドのスタック上にあって物理アドレスがラインサイズの倍数分だけ異なる変数が同じ物理キャッシュラインにマッピングされる可能性があります。その結果、別のスレッドが同じラインの異なる変数に書き込むと、ハードウェアは依然としてコヒーレンシートラフィックを経験し、alignasの指定が隔離に対して不十分となります。
解決策
フォールスシェアリングを回避するためには、データをキャッシュライン全体を消費するようにパディングし、ランタイムのアドレスレイアウトに関係なく、他のデータが物理ストレージを共有しないようにする必要があります。これは、std::hardware_destructive_interference_sizeに合わせてアラインメントされ、サイズが決まった構造体を定義することで実現できます。
#include <new> #include <cstddef> #include <atomic> struct alignas(std::hardware_destructive_interference_size) PaddedCounter { std::atomic<int> value; // パディングがキャッシュラインの残りを埋めて共有を防ぎます char padding[std::hardware_destructive_interference_size - sizeof(std::atomic<int>)]; }; // 配列は各要素が異なるキャッシュラインに存在することを保証します PaddedCounter thread_counters[8];
問題の説明
低遅延の市場データプロセッサは、8つのワーカースレッドを利用し、それぞれがグローバル配列の**std::atomic<int> stats[8]**にスレッドごとのティックカウンターを維持しました。各スレッドは、ロックなしで自分のインデックスのみをインクリメントしましたが、プロファイリングの結果、スループットが理論的最大の一部で頭打ちになり、CPUカウンターはユーザーモードの計算よりも過剰なキャッシュコヒーレンシーサイクルを示しました。調査により、論理的には独立しているはずの整数型が、単一の64バイトのキャッシュラインに連続して詰め込まれていることが確認され、コア間での破壊的干渉が発生していました。
解決策1: ローカルアラインメント変数
チームは最初に、各スレッドの実行関数内でalignas(64) std::atomic<int> local_statを宣言し、モニタースレッドにポインタを渡すことを試みました。このアプローチは最小限のリファクタリングを必要とし、グローバルステートを回避しました。しかし、コンパイラがlocal_statの隣に他の自動変数を配置することができ、異なるスレッドのスタック割り当てが正確な64バイトの倍数で分離される可能性があるため、信頼性に欠けることが判明しました。このため、アラインされた変数が同じ物理ラインにエイリアスしてフォールスシェアリングを引き起こしました。
解決策2: 生ポインタによるヒープ割り当て
別の検討されたアプローチは、**new std::atomic<int>**を介して各カウンタを割り当て、ヒープアロケータが遠くのメモリアドレスに分散して割り当てることを期待していました。この方法は、時には競合を減少させることができましたが、小さな割り当てはしばしば連続したスラブから提供されるため、非決定論的なパフォーマンスを引き起こしました。また、手動のメモリ管理が必要であり、アラインメントやパディングに対するコンパイル時の保証を提供しませんでした。
選択された解決策と結果
最終的な実装は、上記で定義されたPaddedCounter構造体を採用し、インスタンスを静的配列に保存しました。このソリューションが選択された理由は、コンパイル時のパディングとアラインメントを通じてキャッシュラインの分離を決定論的に強制し、ランタイムメモリレイアウトに関係なくハードウェアレベルの競合を排除できたからです。メモリ消費は32バイトから512バイトに増加しましたが、パフォーマンスの向上には受け入れられるものでした。その結果、スループットは12倍の増加を果たし、レイテンシのばらつきが減少し、マイクロ秒未満の処理要件を満たしました。
小さなオブジェクトにalignas(std::hardware_destructive_interference_size)を適用しても、同じスレッド内の他のデータとのフォールスシェアリングを防げない理由は何ですか?
alignasはオブジェクトの開始アドレスのアラインメントのみを制御し、その範囲を制御するものではありません。オブジェクトがキャッシュラインより小さい場合(例:64バイトラインの4バイト整数)、そのキャッシュラインの残りのバイトに他の変数が格納される可能性があります。コンパイラが同じラインに別の変数を配置したり、別のスレッドの変数が同じ物理ラインにマッピングされたりすると、フォールスシェアリングが発生します。真の隔離には、オブジェクトがパディングによってライン全体を占有することが必要であり、単にその開始位置にアラインメントされることではありません。
std::hardware_destructive_interference_sizeとstd::hardware_constructive_interference_sizeの違いは何ですか?また、後者内にデータをグループ化することがパフォーマンスを向上させるのはいつですか?**
std::hardware_destructive_interference_sizeはフォールスシェアリングを避けるために必要な最小の分離を示し、一方、std::hardware_constructive_interference_sizeは単一のキャッシュラインで空間局所性の恩恵を受けるデータの最大サイズです。頻繁にアクセスされる関連フィールド(例:ポイントのx、y、z座標)を構造体にグループ化して構成サイズ内に収めることにより、それらが同じラインに配置されキャッシュヒット率とプリフェッチ効率を最大化します。一方、破壊的サイズは無関係な可変データを分離するために使用されます。
フォールスシェアリングはstd::atomic操作にmemory_order_relaxedを使用する際にどのように影響し、なぜリラックスしたメモリーオーダリングがパフォーマンス低下を解消しないのですか?
memory_order_relaxedを使用しても、周囲のメモリ操作に制約を課さない場合でも、アトミック書き込みは依然としてCPUコアがキャッシュラインの排他的所有権を取得する必要があります(所有権のための読み取りサイクル)。もし別のスレッドが同じライン上の異なる変数を最近変更した場合、キャッシュコヒーレンシープロトコルはラインをコア間で移動させざるを得ません。このハードウェアレベルの同期は、C++メモリモデルの論理的保証とは独立して発生し、フォールスシェアリングは指定されたメモリーオーダリングに関係なく完全なキャッシュミスのレイテンシを引き起こします。