データ指向設計-1-2を超える構造の「メンバー」では非実用的ですか?


23

データ指向設計の通常の例は、ボール構造です。

struct Ball
{
  float Radius;
  float XYZ[3];
};

そして、彼らはstd::vector<Ball>ベクトルを繰り返すアルゴリズムを作ります。

次に、同じことを提供しますが、データ指向設計で実装されます。

struct Balls
{
  std::vector<float> Radiuses;
  std::vector<XYZ[3]> XYZs;
};

最初にすべての半径で、次にすべての位置などで反復する場合、これは良いことです。ただし、ベクター内のボールをどのように移動しますか?元のバージョンでは、を持っている場合std::vector<Ball> BallsAll、any BallsAll[x]をanyに移動できますBallsAll[y]

ただし、データ指向バージョンでこれを行うには、すべてのプロパティに対して同じことを行う必要があります(ボールの場合は2回-半径と位置)。しかし、より多くのプロパティがある場合は悪化します。「ボール」ごとにインデックスを保持する必要があり、それを移動しようとすると、プロパティのすべてのベクトルで移動する必要があります。

それは、データ指向設計のパフォーマンス上の利点を損なうものではありませんか?

回答:


23

別の答えは、行指向ストレージをどのようにうまくカプセル化し、より良いビューを提供するかについて優れた概要を示しました。ただし、パフォーマンスについても質問するため、SoAレイアウトは特効薬ではありません。これはかなり良いデフォルトです(キャッシュの使用に関しては、ほとんどの言語での実装を容易にするためではありません)が、データ指向設計においてもそうではありません(それが何を意味するにせよ)。あなたが読んだいくつかの紹介記事の著者は、その点を見逃して、SoAレイアウトのみを提示している可能性があります。彼らは間違っているだろうし、ありがたいことに誰もがそのtrapに陥るわけではない

おそらく既に気付いているように、プリミティブデータのすべての部分が独自の配列に引き出されることで恩恵を受けるわけではありません。SoAレイアウトは、通常、個別の配列に分割したコンポーネントに個別にアクセスする場合に役立ちます。ただし、すべての小さなピースが単独でアクセスされるわけではありません。たとえば、ほとんどの場合、位置ベクトルは大まかに読み取られて更新されるため、当然、そのベクトルは分割されません。実際、あなたの例でもそれをしませんでした!同様に、通常ボールのすべてのプロパティに同時にアクセスする場合ほとんどの時間をボールのコレクション内のボールの交換に費やしているため、それらを分離しても意味がありません。

ただし、DODにはもう1つの側面があります。メモリレイアウトを90°に変更し、結果として生じるコンパイルエラーの修正を最小限に抑えるだけでは、キャッシュと組織の利点をすべて得ることはできません。このバナーの下で教えられる他の一般的なトリックがあります。たとえば、「存在ベースの処理」:ボールを頻繁に非アクティブ化して再アクティブ化する場合、ボールオブジェクトにフラグを追加せず、フラグがfalseに設定されたボールを更新ループで無視しないようにします。ボールを「アクティブ」コレクションから「非アクティブ」コレクションに移動し、更新ループで「アクティブ」コレクションのみを検査します。

より重要かつあなたの例に関連して:ボール配列をシャッフルするのに多くの時間を費やしているなら、あなたは何か間違ったことをしているかもしれません。なぜ順序が重要なのですか?どうでもいいですか?その場合、いくつかの利点が得られます。

  • コレクションをシャッフルする必要はありません(最速のコードはコードなしです)。
  • より簡単かつ効率的に追加および削除できます(最後までスワップ、最後にドロップ)。
  • 残りのコードは、さらなる最適化(対象となるレイアウトの変更など)に適格になる場合があります。

そのため、盲目的にSoAをすべてに投げかけるのではなく、データとその処理方法について考えてください。位置と速度を1つのループで処理し、メッシュを通過し、ヒットポイントを更新する場合は、メモリレイアウトをこれらの3つの部分に分割してみてください。位置のx、y、zコンポーネントに単独でアクセスしていることがわかった場合、位置ベクトルをSoAに変換することができます。実際に何か役に立つことをするよりもデータをシャッフルすることに気付いた場合は、シャッフルをやめることもできます。


18

データ指向のマインドセット

データ指向の設計は、SoAsをあらゆる場所に適用することを意味しません。それは単に、データ表現に重点を置いて、特に効率的なメモリレイアウトとメモリアクセスに焦点を当てたアーキテクチャを設計することを意味します。

そうすると、適切な場合にSoA担当者につながる可能性があります。

struct BallSoa
{
   vector<float> x;        // size n
   vector<float> y;        // size n
   vector<float> z;        // size n
   vector<float> r;        // size n
};

...これは、球の中心ベクトル成分と半径を同時に処理しない(4つのフィールドが同時にホットではない)垂直ループロジックに適していますが、代わりに一度に1つ(半径を通るループ、別の3つのループ)球体の中心の個々のコンポーネントを介して)。

他の場合、フィールドに頻繁にアクセスする場合(ループロジックが個別ではなくボールのすべてのフィールドを繰り返し処理する場合)および/またはボールのランダムアクセスが必要な場合は、AoSを使用する方が適切な場合があります。

struct BallAoS
{
    float x;
    float y;
    float z;
    float r;
};
vector<BallAoS> balls;        // size n

...他の場合には、両方の利点のバランスをとるハイブリッドを使用することが適切かもしれません:

struct BallAoSoA
{
    float x[8];
    float y[8];
    float z[8];
    float r[8];
};
vector<BallAoSoA> balls;      // size n/8

...ハーフフロートを使用してボールのサイズを半分に圧縮し、より多くのボールフィールドをキャッシュライン/ページに収めることもできます。

struct BallAoSoA16
{
    Float16 x2[16];
    Float16 y2[16];
    Float16 z2[16];
    Float16 r2[16];
};
vector<BallAoSoA16> balls;    // size n/16

...おそらく半径も球体の中心ほど頻繁にはアクセスされません(おそらく、コードベースはしばしばそれらを点のように扱い、まれにしか球体として扱いません)。その場合は、ホット/コールドフィールド分割手法をさらに適用できます。

struct BallAoSoA16Hot
{
    Float16 x2[16];
    Float16 y2[16];
    Float16 z2[16];
};
vector<BallAoSoA16Hot> balls;     // size n/16: hot fields
vector<Float16> ball_radiuses;    // size n: cold fields

データ指向設計の鍵は、設計の決定を下す前にこれらの種類の表現をすべて検討し、その背後にあるパブリックインターフェイスを使用して次善の表現に陥らないようにすることです。

メモリアクセスパターンとそれに伴うレイアウトにスポットライトを当て、それらを通常よりもはるかに強い懸念にしています。ある意味では、抽象化をいくらか壊すことさえあります。この考え方を適用することでstd::deque、たとえば、アルゴリズムの要件という点で、集約された連続ブロックの表現と、メモリレベルでのランダムアクセスのしくみについて、もう見ていません。実装の詳細にやや重点を置いていますが、スケーラビリティを説明するアルゴリズムの複雑さと同じかそれ以上のパフォーマンスに影響を与える傾向がある実装の詳細です。

時期尚早の最適化

データ指向設計の主な焦点の多くは、少なくとも一見して、危険なほど時期尚早の最適化に近いように見えます。多くの場合、経験から、このようなマイクロ最適化は後知恵で、プロファイラーを使用して最適に適用されることがわかります。

しかし、おそらくデータ指向の設計から得られる強いメッセージは、そのような最適化の余地を残すことです。それが、データ指向の考え方が可能にするものです:

データ指向の設計では、より効果的な表現を探求するための余裕を残すことができます。必ずしも一度にメモリレイアウトの完全性を達成することではなく、最適化された表現を可能にするために事前に適切な検討を行うことです。

粒度の高いオブジェクト指向設計

多くのデータ指向設計の議論は、オブジェクト指向プログラミングの古典的な概念に反するものです。しかし、私はこれを見る方法を提供しますが、これはOOPを完全に却下するほどハードコアではありません。

オブジェクト指向設計の難しさは、非常にきめ細かなレベルでインターフェイスをモデル化することをしばしば誘惑することであり、パラレルバルクマインドセットの代わりにスカラーの1つずつのマインドセットにとらわれます。

誇張された例として、画像の単一のピクセルに適用されるオブジェクト指向のデザインの考え方を想像してください。

class Pixel
{
public:
    // Pixel operations to blend, multiply, add, blur, etc.

private:
    Image* image;          // back pointer to access adjacent pixels
    unsigned char rgba[4];
};

誰も実際にこれをしないことを願っています。サンプルを非常に粗くするために、ピクセルを含む画像へのバックポインターを保存して、ぼかしなどの画像処理アルゴリズムの隣接ピクセルにアクセスできるようにしました。

画像のバックポインターはすぐにギラギラしたオーバーヘッドを追加しますが、それを除外しても(ピクセルのパブリックインターフェイスのみが単一のピクセルに適用される操作を提供するように)、ピクセルを表すクラスだけになります。

これで、C ++コンテキストでは、このバックポインター以外の直接的なオーバーヘッドの意味でクラスに問題はありません。C ++コンパイラの最適化は、私たちが構築したすべての構造を取得し、それをsmithereensに抹消するのに優れています。

ここでの難点は、カプセル化されたインターフェイスを非常に粒度の細かいピクセルレベルでモデリングしていることです。そのため、この種のきめ細かい設計とデータにとらわれ、潜在的に膨大な数のクライアントの依存関係がこのPixelインターフェイスに結びついています。

解決策:オブジェクト指向の粒状ピクセルの構造を消去し、大量のピクセルを処理するより粗いレベル(イメージレベル)でインターフェイスのモデリングを開始します。

バルクイメージレベルでモデリングすることにより、最適化する余地が大幅に広がります。たとえば、64バイトのキャッシュラインに完全に適合する16x16ピクセルの合体タイルとして大きな画像を表すことができますが、通常は小さなストライドでピクセルの効率的な隣接垂直アクセスを許可します(多くの画像処理アルゴリズムがある場合筋金入りのデータ指向の例として、垂直方向に隣接するピクセルにアクセスする必要があります。

より粗いレベルでの設計

画像レベルでのモデリングインターフェースの上記の例は、画像処理が非常に成熟した分野であり、研究され、死に最適化されているため、一種の簡単な例です。しかし、パーティクルエミッタ内のパーティクル、スプライトとスプライトのコレクション、エッジのグラフのエッジ、または人と人のコレクションである場合もあります。

データ指向の最適化(先見性または後知性)を可能にするための鍵は、多くの場合、はるかに大まかなレベルでインターフェイスを一括して設計することです。単一のエンティティのインターフェイスを設計するという考え方は、エンティティをまとめて処理する大きな操作を持つエンティティのコレクションを設計することで置き換えられます。これは特に、すべてにアクセスする必要があり、線形の複雑さを持たざるを得ないシーケンシャルアクセスループを即座に対象としています。

多くの場合、データ指向の設計は、データを結合して集約モデリングデータを一括して作成するというアイデアから始まります。同様の考え方が、それに付随するインターフェース設計にも反映されています。

これは、データ指向設計から得た最も価値のある教訓です。なぜなら、私はコンピューターアーキテクチャに精通していないため、最初の試みで何かに最適なメモリレイアウトを見つけることができるからです。プロファイラーを手に持って反復するものになります(場合によっては、スピードアップに失敗した途中でいくつかのミスを伴います)。しかし、データ指向設計のインターフェイス設計の側面は、より効率的なデータ表現を求める余地を残しています。

重要なのは、私たちが通常やろうと思っているよりも粗いレベルでインターフェースを設計することです。これには、仮想関数、関数ポインター呼び出し、dylib呼び出し、インライン化できないことに関連する動的ディスパッチオーバーヘッドを軽減するなどの副次的な利点もあります。これらすべてを取り除くための主なアイデアは、処理を一括して見ることです(該当する場合)。


5

説明したのは実装の問題です。オブジェクト指向の設計は、実装に特に関係しません

列指向のBallコンテナを、行指向または列指向のビューを公開するインターフェースの背後にカプセル化できます。volumeおよびなどのメソッドを使用して、Ballオブジェクトを実装できますmove。これらのメソッドは、基になる列方向の構造のそれぞれの値を変更するだけです。同時に、Ballコンテナーは、列ごとの効率的な操作のためのインターフェイスを公開できます。適切なテンプレート/タイプと巧妙なインラインコンパイラを使用すると、これらの抽象化をランタイムコストなしで使用できます。

データを列単位でアクセスする場合と行単位で変更する場合の頻度はどれくらいですか?列ストレージの一般的な使用例では、行の順序は影響しません。別のインデックス列を追加することにより、行の任意の順列を定義できます。順序を変更するには、インデックス列の値を交換するだけで済みます。

要素の効率的な追加/削除は、他の手法で実現できます。

  • 要素をシフトする代わりに、削除された行のビットマップを維持します。構造がまばらになったら、構造を圧縮します。
  • 任意の位置での挿入または削除が構造全体を変更することを必要としないように、Bツリーのような構造で適切なサイズのチャンクに行をグループ化します。

クライアントコードには、Ballオブジェクトのシーケンス、Ballオブジェクトの可変コンテナ、半径のシーケンス、Nx3マトリックスなどが表示されます。それらの複雑な(しかし効率的な)構造のい詳細を気にする必要はありません。それがオブジェクトの抽象化によって得られるものです。


+1 AoS組織は、優れたエンティティ指向のAPIに完全に修正可能ですが、擬似ポインターを介してコヒーレントエンティティを偽造したい場合を除き、使用することは明らかにい(ball->do_something();ball_table.do_something(ball))になります(&ball_table, index)

1
さらに一歩進めます。SoAを使用するという結論は、オブジェクト指向の設計原則から純粋に到達することができます。秘Theは、列が行よりも基本的なオブジェクトであるシナリオが必要だということです。ここではボールは良い例ではありません。代わりに、高さ、土壌タイプ、降雨量などのさまざまなプロパティを持つ地形を検討してください。各プロパティはScalarFieldオブジェクトとしてモデル化され、gradient()やdivergence()などの他のFieldオブジェクトを返す独自のメソッドを持っています。マップの解像度などをカプセル化でき、地形のさまざまなプロパティがさまざまな解像度で機能します。
16807

4

簡単な答え:あなたは完全に正しいです、そしてこのような記事はこの点を完全に欠いています。

完全な答えは次のとおりです。例の「配列の構造」アプローチは、ある種の操作(「列操作」)でパフォーマンス上の利点があり、他の種類の操作(「行操作」で「配列の配列」 「あなたが上で言及したもののように)。同じ原理があり、データベースのアーキテクチャに影響を与えた列指向データベース古典列指向データベース対は、

したがって、設計を選択するために考慮する2番目のことは、プログラムで最も必要な操作の種類と、それらが異なるメモリレイアウトの恩恵を受けるかどうかです。ただし、最初に考慮すべきことは、そのパフォーマンスが本当に必要な場合です(ゲームプログラミングでは、上記の記事はこの要件を頻繁に満たしていると思います)。

現在のほとんどのオブジェクト指向言語は、オブジェクトとクラスに「配列の構造」メモリレイアウトを使用します。OOの利点(データの抽象化の作成、カプセル化、基本機能のローカルスコープなど)の取得は、通常、この種のメモリレイアウトにリンクされています。したがって、高性能コンピューティングを行わない限り、SoAを主要なアプローチとは見なしません。


3
DODは常に構造体配列(SoA)レイアウトを意味するわけではありません。よくあるのは、アクセスパターンと一致することが多いためですが、別のレイアウトがより適切に機能する場合は、必ずそれを使用します。DODは、データをレイアウトする特定の方法よりもはるかに一般的(かつファジー)であり、設計パラダイムに似ています。また、参照する記事は最良のリソースとはほど遠いものであり、欠点もありますが、SoAレイアウトを宣伝するものではありませ。「A」および「B」Ballは、個々floatのsまたはvec3s(それ自体がSoA変換の対象となる可能性がある)と同様に、完全な機能を持つことができます。

2
...そして、あなたが言及する行指向の設計は常にDODに含まれています。これは構造の配列(AoS)と呼ばれ、ほとんどのリソースが「OOPの方法」と呼ぶものとの違いは(行方または列順)、行と列のレイアウトではなく、単にこのレイアウトがメモリにマッピングされる方法(多くの小さなオブジェクト)ポインタを介してリンクされているか、すべてのレコードの大きな連続テーブルにリンクされています)。要約すると、OPの誤解に対して良い点を挙げているにもかかわらず、OPのDODの理解を修正するのではなく、DODジャズ全体を誤って伝えているためです。

@delnan:コメントありがとうございます。「DOD」の代わりに「SoA」という用語を使用すべきだったのはおそらく正しいでしょう。それに応じて答えを編集しました。
Doc Brown 14

はるかに良い、downvoteが削除されました。SoAを優れた「オブジェクト」指向のAPI(抽象化、カプセル化、および「より基本的な機能のローカルスコープ」という意味)と統合する方法については、user2313838の回答をご覧ください。AoSレイアウトの場合はより自然になります(配列は要素型と結婚するのではなく、ダムの汎用コンテナーになる可能性があるため)が、実現可能です。

そして、このgithub.com/BSVino/JaiPrimer/blob/master/JaiPrimer.mdには、SoAからAoSへの自動変換があります。例: reddit.com/r/rust/comments/2t6xqz/… そして、ニュース
ジェリーエレミヤ
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.