データ指向のマインドセット
データ指向の設計は、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呼び出し、インライン化できないことに関連する動的ディスパッチオーバーヘッドを軽減するなどの副次的な利点もあります。これらすべてを取り除くための主なアイデアは、処理を一括して見ることです(該当する場合)。
ball->do_something();
対ball_table.do_something(ball)
)になります(&ball_table, index)
。