web-dev-qa-db-ja.com

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

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

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がある場合は、BallsAll[x]を任意のBallsAll[y]に移動できます。

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

それはデータ指向設計のパフォーマンス上の利点を殺しませんか?

24
ulak blade

別の答え は、行指向のストレージを適切にカプセル化し、より良いビューを提供する方法について優れた概要を示しました。しかし、パフォーマンスについても質問があるので、それに対処させてください:SoAレイアウトは特効薬ではありません。これはかなり良いデフォルトです(キャッシュの使用については、ほとんどの言語での実装が簡単になるほどで​​はありません)。あなたが読んだいくつかの紹介の作成者は、それがDODの全体のポイントであると考えているため、そのポイントを逃して、SoAレイアウトのみを提示している可能性があります。彼らは間違っているでしょう、そしてありがたいことに 誰もがその罠に陥るわけではありません

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

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

あなたの例にとってより重要で関連性があります:ボール配列をシャッフルするのに多くの時間を費やしている場合、おそらく何かが間違っています。なぜ注文が重要なのですか?あなたはそれを問題にしないことができますか?その場合、いくつかのメリットがあります。

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

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

24
user7043

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

データ指向の設計は、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呼び出し、インライン化できないなどの副次的な利点もあります。これらすべてを取り除く主なアイデアは、処理をまとめて見ることです(該当する場合)。

21
user204677

あなたが説明したのは実装の問題です。 OOデザインは明確にnot実装に関係しています。

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

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

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

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

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

7
user2313838

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

完全な答えは次のとおりです。例の「Structure-Of-Arrays」アプローチは、ある種の操作(「列操作」)の場合はパフォーマンス上の利点があり、「Arrays-of-Structs」は他の種類の操作(「行操作」)の場合があります。 "、あなたが上で述べたもののように)。同じ原理がデータベースアーキテクチャに影響を与えています。 列指向データベース と従来の行指向データベースがあります。

したがって、second設計を選択するために考慮すべきことは、プログラムで最も必要な操作の種類であり、それらが異なるメモリレイアウトから利益を得るかどうかです。 。ただし、最初の考慮すべきことは、あなたが本当にがそのパフォーマンスを必要とするかどうかです(ゲームプログラミングでは、上記の記事があなたからのものである場合、この要件がよくあります)。

最新のOO言語は、オブジェクトとクラスに「Array-Of-Struct」メモリレイアウトを使用します。OOの利点の取得(データの抽象化の作成など) 、カプセル化、および基本機能のよりローカルなスコープ)は、通常、この種のメモリレイアウトにリンクされます。したがって、ハイパフォーマンスコンピューティングを行わない限り、SoAを主要なアプローチとは見なしません。

5
Doc Brown