HN 日本語サマリー

← 一覧へ戻る
プログラミング

Rustにおけるキャッシュ効率の良いデータレイアウト:フィールドゾーニング、偽共有、128バイトルール

Cache-Conscious Data Layout in Rust: Field Zoning, False Sharing, 128-Byte Rule (debasishg.github.io)

28 pointsby eigenBasis15 コメント

要約

この記事は、Rustで高スループット・低レイテンシのシステムを構築するための低レベル設計に焦点を当てています。特に、CPUキャッシュの効率を最大化するために、データ構造のフィールドをアクセスパターン(書き込み所有者、アクセス頻度)に基づいて「ゾーン」に分割し、キャッシュラインの共有によるパフォーマンス低下(偽共有)を防ぐ方法を解説しています。また、`#[repr(C)]`属性の重要性や、キャッシュラインのサイズ(一般的に64バイトだが、128バイトが最適となる場合もある)についても説明しています。

全文翻訳

Rustにおけるキャッシュ効率の良いデータレイアウト:フィールドゾーニング、偽共有、128バイトルール キャッシュ効率の良いデータレイアウト:フィールドゾーニング、偽共有、128バイトルール Rustにおける低レベルシステム設計のパート1。高スループット、低レイテンシのシステムコードを書くためのシリーズで、シングルプロデューサー/シングルコンシューマー(SPSC)リングバッファを実際の例として使用します。パート0 - アーキテクチャル分解では、最も効果的な決定(構造的に競合を排除し、各ライターが独自のリングを持つようにする)を行い、シリーズ全体のインデックスと指針となる原則が示されました。この記事では、マイクロレベルの作業を開始します。1つのSPSCリングバッファを与えられたとき、それはメモリ上でどのようにレイアウトされるべきでしょうか?これらのパターンはリングバッファに固有のものではなく、複数のコアが触れるあらゆる構造に適用できます。 マルチスレッド構造体について誰も教えてくれないこと シングルスレッドのデータ構造を書くとき、唯一重要なレイアウトの質問は「キャッシュに収まるか?」です。マルチスレッドの構造体を書くとき、2番目の、関連性があり少し微妙な質問が現れます。各フィールドについて、どのコアがそれを触り、どのくらいの頻度で触るのか?これを間違えると、プロファイラが単一のホットラインとしてフラグを立てない方法で代償を払うことになります。2つのコアが、偶然同じ64バイトのキャッシュラインを共有する異なるフィールドに書き込むと、ハードウェアコヒーレンシプロトコルを通じて静かに互いをシリアライズします。これは偽共有と呼ばれる病理です。コードはロックフリーに見えます。ベンチマークはそうではないと言います。この記事は、レイアウトを意図的に設計することについてです。それは2つのパートに分かれます。 フィールドゾーニング - フィールドを(書き込み所有者、頻度)でグループ化し、1つのコアのホットな書き込みが別のコアのホットワーキングセットを追い出さないようにする。 アライメントとパディング - ゾーニングを現実にするメカニズム:なぜ#[repr(C)]が負荷を担うのか、なぜマジックナンバーが64ではなく128であることが多いのか、そしてプリフェッチヒントを追加すると状況が悪化する可能性がある理由。 実際の例は、シングルプロデューサー/シングルコンシューマー(SPSC)リングバッファです。1つのコア(プロデューサー)が追加し、別のコア(コンシューマー)が消費します。それらは2つの単調なカーソル(テイル:プロデューサーが次に書き込む場所、ヘッド:コンシューマーが次に読み取る場所)を通じて協調します。以下の議論と設計原則は十分に一般的ですが、これらの指針を尊重する参照点としてこのリングバッファ実装を参照できます。 パート1 - フィールドゾーニング: "誰が何に触れるか" に基づく設計 キャッシュライン - x86-64および多くのAArch64コアでは一般的に64バイト - は、コア間通信の通貨単位です。コヒーレンシトラフィックは、バイトごとやフィールドごとではなく、ラインごとに計上されます。したがって、共有構造体の最初の設計上の移動は、アクセスパターンによってフィールドをゾーンにソートすることです。 Zone Fields Write owner Cross-core access Producer-hot tail, cached_head producer consumer samples tail Consumer-hot head, cached_tail consumer producer samples head Cold closed, config, metrics lifecycle/observability rare, not hot-path polling "Producer-hot" は "producer-only" を意味しません。プロデューサーはテイルへの書き込みを所有しますが、コンシューマーはキャッシュされたビューが枯渇したときにテイルを読み取ります。重要な区別は書き込み所有権です。プロデューサーはラインを排他的にし続けるコアなので、その書き込みに近いフィールドは慎重に選択する必要があります。 まず、語彙です。ポストの残りはそれに依存します。ホットパスは、ほぼすべての操作で実行されるコードです。ここでは、各コアが毎秒数百万回実行する送受信ループです。ホットフィールドは、それらのループが触れるフィールドです。テイル、ヘッド、および2つのカーソルキャッシュなどです。対照的に、コールドパスは、まれに実行されるすべてのものであり、構築、シャットダウン、時折のメトリクス読み取りなどです。コールドフィールドは、コールドパスのみが触れるフィールドです。設定やメトリクスなどです。「ホット」と「コールド」はアクセスの頻度に関するものであり、上記のテーブルのゾーンはそれらによってソートされています。ルールは単純に述べることです。各ゾーンを独自のキャッシュラインセットに配置します。1つのコアによって書き込まれるホットフィールドは、別のコアが頻繁に読み取ったり書き込んだりするホットフィールドと同じラインを共有してはなりません。プロデューサーがテイルにストアすることは、コンシューマーのヘッドまたはcached_tailを含むラインを無効にしてはなりません。逆も同様です。コールドフィールドは、ホットパスで誰も競合しないため、後部にまとめてパックできます。 ここに、それらのゾーンを宣言に直接エンコードしたリングバッファがあります。型パラメータAは、バックエンドバッファのプラグ可能なアロケータにすぎません。無視しても構いません。重要なのはフィールドの順序とグループ化です。 #[repr(C)] pub struct Ring<T, A: BufferAllocator = HeapAllocator> { // PRODUCER HOT tail: CacheAligned<AtomicU64>, cached_head: CacheAligned<UnsafeCell<u64>>, // CONSUMER HOT head: CacheAligned<AtomicU64>, cached_tail: CacheAligned<UnsafeCell<u64>>, // COLD closed: AtomicBool, metrics: Metrics, config: Config, buffer: UnsafeCell<A::Buffer<T>>, } ここに注意深く読むべきことがいくつかあります。それぞれがスタイルによる事故ではなく、意図的な選択だからです。 2つのcached_*フィールドは、コールドゾーンではなく、ホットゾーンにあります。スペースがあるかどうかを知りたいプロデューサーは、テイルとヘッドを比較する必要があります。しかし、ヘッドは他のコアによって書き込まれるため、直接読み取ることは潜在的なコア間コヒーレンスミス(数十から数百サイクル)になります。代わりに、プロデューサーはcached_headを保持します。これは、最後に見たときのコンシューマーの位置のシングルライター、プロデューサー所有のスナップショットです。ヘッドの古いビューは、利用可能なスペースを過少報告することはあっても、過大報告することは決してないため、キャッシュは高速パスで常に信頼できます。実際のヘッドの高価なAcquire読み取りは、キャッシュが部屋がないと判断した場合にのみ発生します。そのスナップショットは、すべての送信で触れられるため、プロデューサーホットゾーンに属します。そして、それは単なるUnsafeCell<u64>であり、アトミックではないのは、1つのコアだけがそれを書き込むからです。 コンシューマーは鏡像を持っています。cached_tailは、ローカルスナップショットが枯渇するまで、プロデューサー所有のテイルを読み取るのを避けることができます。 コールドフィールドは意図的にラインを共有します。closed、metrics、configは、それらの間にパディングなしでパックされています。それは怠慢ではありません。それがポイントです。ゾーニングの目標はすべてをアラインすることではありません。それは、コア間でpingするものをアラインすることだけです。コールドフィールドにパディングを追加すると、ホットデータを常駐させておくために費やせたキャッシュラインが無駄になります。ライフサイクルフラグが送受信ごとにポーリングされる場合、それはもはやコールドではありません。それを "シャットダウン" という言葉の後ろに隠すのではなく、独自のホットゾーンまたはライフサイクルゾーンに昇格させてください。 なぜ#[repr(C)]が実際の仕事をしているのか 構造体上の#[repr(C)]に注目してください。装飾的なものではなく、それを削除すると、レイアウト戦略全体が静かに損なわれます。デフォルトでは、Rustはrepr(Rust)を使用し、Rust Referenceは、repr(Rust)がフィールドのアライメントのみを保証し、フィールドが重複せず、集計が十分にアラインされていることを明示しています。フィールドが宣言順にレイアウトされることは保証されていません。コンパイラはそれらを自由に並べ替えることができます(通常はパディングを最小限に抑えるため)。通常の構造体にとっては機能です。パフォーマンスの正確性がテイルとヘッドが異なるゾーンに配置されることに依存する構造体の場合、並べ替えは慎重に分離されたゾーンを共有ラインに折りたたむ可能性があります。#[repr(C)]はフィールドを宣言順に固定するため、記述したゾーンレイアウトが取得するゾーンレイアウトになります。 見落としやすい微妙な点があります。外側の構造体上のrepr(C)は、ネストされたrepr(Rust)フィールドのレイアウトを再帰的に凍結しません。Ring自体のフィールドの順序は固定しますが、例えばMetricsやConfigの内部順序は、それらの型が独自のrepr(C)を持たない限り、依然としてrepr(Rust)です。ネストされた構造体自体がホット/コールドの分割が重要である場合、それにもrepr(C)を与えてください。ここでは、MetricsとConfigは完全にコールドなので、問題ありません。 パート2 - アライメントと偽共有:ゾーニングを現実にする ゾーニングは意図です。アライメントはメカニズムです。テイルとヘッドが異なるゾーンに属すると宣言しても、バイトが実際に異なるキャッシュラインに着地しない限り、何も達成されません。それがCacheAligned<T>の仕事です。元が取れる1行ヘルパー /// 128バイトに値をアラインします