実際のデータ指向設計

最近では、興味深いが、あまり一般的ではないパラダイム、いわゆるデータ指向設計DOD )についての議論を見つけることがますます多くなっています。 高性能コンピューティングに関連する仕事に応募する場合は、関連する問題に備えてください。 しかし、同僚の何人かがこのアプローチについて聞いておらず、短い議論の後、彼らが懐疑的だったことを知って非常に驚きました。 この記事では、従来のOOPアプローチとDODを比較します。



DODとは何ですか?



この記事は、その本質を説明しようとせずに異なるアプローチを比較する試みとして考案されました。 Habréには、テーマに関する記事がいくつかあります。たとえば CppConカンファレンスのビデオご覧ください 。 しかし、簡単に言えば、 DODキャッシュフレンドリーな方法でデータを処理する方法です。 理解できないように聞こえますが、例を挙げて説明します。



#include <chrono> #include <iostream> #include <vector> using namespace std; using namespace std::chrono; struct S { uint64_t u; double d; int i; float f; }; struct Data { vector<uint64_t> vu; vector<double> vd; vector<int> vi; vector<float> vf; }; int test1(S const & s1, S const & s2) { return s1.i + s2.i; } int test2(Data const & data, size_t const ind1, size_t const ind2) { return data.vi[ind1] + data.vi[ind2]; } int main() { size_t const N{ 30000 }; size_t const R{ 10 }; vector<S> v(N); Data data; data.vu.resize(N); data.vd.resize(N); data.vi.resize(N); data.vf.resize(N); int result{ 0 }; cout << "test #1" << endl; for (uint32_t i{ 0 }; i < R; ++i) { auto const start{ high_resolution_clock::now() }; for (size_t a{ 0 }; a < v.size() - 1; ++a) { for (size_t b{ a + 1 }; b < v.size(); ++b) { result += test1(v[a], v[b]); } } cout << duration<float>{ high_resolution_clock::now() - start }.count() << endl; } cout << "test #2" << endl; for (uint32_t i{ 0 }; i < R; ++i) { auto const start{ high_resolution_clock::now() }; for (size_t a{ 0 }; a < v.size() - 1; ++a) { for (size_t b{ a + 1 }; b < v.size(); ++b) { result += test2(data, a, b); } } cout << duration<float>{ high_resolution_clock::now() - start }.count() << endl; } return result; }
      
      







2番目のテストは30%速く実行されます(VS2017およびgcc7.0.1で)。 しかし、なぜですか?



S



構造のサイズは24バイトです。 私のプロセッサ(Intel Core i7)には、コアあたり32KBキャッシュと64Bキャッシュラインがあります。 つまり、メモリからデータを要求する場合、2つのS



構造のみが1つのキャッシュラインに完全に収まります。 最初のテストでは、1つのint



フィールドのみを読み取りました。 1つのメモリアクセスで、1つのキャッシュラインに必要なフィールドは2つ(場合によっては3つ)になります。 2番目のテストでは、同じint



値を読み取りますが、ベクターから読み取ります。 std::vector



はデータの一貫性を保証します。 これは、1つのキャッシュラインでメモリにアクセスするときに、16( 64B / sizeof(int) = 16



)の値が必要になることを意味します。 2番目のテストでは、メモリへのアクセス頻度が低くなります。 ご存知のように、メモリアクセスは、最新のプロセッサでは弱いリンクです。



実際にはどのようになっていますか?



上記の例は、 AoS (構造体の配列)の代わりにSoA (配列の構造体)を使用する利点を示していますが、この例はHello World



カテゴリ、つまり 実生活とはほど遠い。 実際のコードには、多くの依存関係と特定のデータがありますが、おそらくパフォーマンスは向上しません。 さらに、テストで構造のすべてのフィールドを参照する場合、パフォーマンスに違いはありません。



アプローチの適用の現実を理解するために、両方の手法を使用して多少複雑なコードを記述し、結果を比較することにしました。 ソリッドの2Dシミュレーションとします-N個の凸ポリゴンを作成し、質量、速度などのパラメーターを設定します。 30 fpsに留まることをシミュレートできるオブジェクトの数を確認します。



1.構造の配列



1.1。 プログラムの最初のバージョン



プログラムの最初のバージョンのソースコードは、 このコミットから取得できます。 次に、コードについて簡単に説明します。



簡単にするために、プログラムはWindows用に作成されており、レンダリングにDirectX11を使用しています。 この記事の目的は、プロセッサーのパフォーマンスを比較することなので、グラフィックスについては説明しません。 肉体を表すShape



クラスは次のようになります。



Shape.h
 class Shape { public: Shape(uint32_t const numVertices, float radius, math::Vec2 const pos, math::Vec2 const vel, float m, math::Color const col); static Shape createWall(float const w, float const h, math::Vec2 const pos); public: math::Vec2 position{ 0.0f, 0.0f }; math::Vec2 velocity{ 0.0f, 0.0f }; math::Vec2 overlapResolveAccumulator{ 0.0f, 0.0f }; float massInverse; math::Color color; std::vector<math::Vec2> vertices; math::Bounds bounds; };
      
      









画像






三角形が図形aと交差する場合、図形が重ならないように少し移動する必要があります。 また、 bounds



を再カウントする必要がありbounds



。 しかし、三角形を移動した後、別の図形-bと交差し、再び移動してbounds



カウントする必要がありbounds



。 2番目の移動の後、三角形は再び図aの上になります。 計算の繰り返しを避けるために、三角形を移動するために必要な値を特別なバッテリーにoverlapResolveAccumulator



そして、後でこの値にフィギュアを移動しますが、一度だけです。



プログラムの中心は、 ShapesApp::update()



メソッドです。 以下はその簡略版です。



ShapesApp.cpp
 void ShapesApp::update(float const dt) { float const dtStep{ dt / NUM_PHYSICS_STEPS }; for (uint32_t s{ 0 }; s < NUM_PHYSICS_STEPS; ++s) { updatePositions(dtStep); for (size_t i{ 0 }; i < _shapes.size() - 1; ++i) { for (size_t j{ i + 1 }; j < _shapes.size(); ++j) { CollisionSolver::solveCollision(_shapes[i].get(), _shapes[j].get()); } } } }
      
      







各フレームでShapesApp::updatePositions()



メソッドを呼び出します。このメソッドは、各形状の位置を変更し、新しいShape::bounds



を計算しShape::bounds



。 次に、各図の交差点CollisionSolver::solveCollision()



ます。 軸分離定理(SAT)を使用しました。 これらすべてのチェックをNUM_PHYSICS_STEPS



回行います。 この変数にはいくつかの目的があります。1つ目は物理がより安定し、2つ目は画面上のオブジェクトの数を制限することです。 C ++は高速で非常に高速であり、この変数がなければ何万もの形状があり、レンダリングが遅くなります。 NUM_PHYSICS_STEPS = 20



を使用しNUM_PHYSICS_STEPS = 20







私の古いラップトップでは、このプログラムはfpsが30を下回り始めるまでに最大500個を計算します。Fuuuu、わずか500 ???! 少し同意しますが、すべてのフレームが20回計算を繰り返すことを忘れないでください。



スクリーンショットで記事を希釈する価値があると思うので、ここに:



画像



1.2。 最適化番号1.空間グリッド



私は、できるだけ現実に近いプログラムでテストを実施したいと述べました。 上記で書いたものは、実際には使用されません-各スーと各図をゆっくりチェックするために。 計算を高速化するために、通常、特別な構造が使用されます。 NxMセルで構成されるGrid



と呼ばれる通常の2Dグリッドを使用することをお勧めします。 計算の開始時に、各セルに含まれるオブジェクトを記録します。 次に、すべてのセルを調べて、オブジェクトのいくつかのペアの交差を確認するだけです。 私はリリースでこのアプローチを繰り返し使用してきましたが、非常に迅速に記述され、簡単にデバッグされ、理解しやすいことが実証されています。



プログラムの2番目のバージョンのコミットは、 ここで確認できます 。 新しいGrid



クラスが登場し、 ShapesApp::update()



メソッドが少し変更されました-グリッドメソッドを呼び出して、交差をチェックします。



このバージョンでは、すでに30 fpsで8000個の数字を保持しています(各フレームで約20回の反復を忘れないでください) 数字がウィンドウに収まるように、数字を10倍減らす必要がありました。



画像



1.3。 最適化番号2。マルチスレッド。



今日、4つのコアを備えたプロセッサでさえ電話にインストールされている場合、マルチスレッドを無視するのは愚かなことです。 この最後の最適化では、スレッドのプールを追加し、メインタスクを等しいタスクに分割します。 そのため、たとえば、以前はすべての図形を実行し、新しい位置を設定してbounds



を再ShapesApp::updatePositions



するShapesApp::updatePositions



は、図形の一部に沿ってのみ実行されるようになり、1つのコアの負荷が軽減されます。 プールクラスは、ここから正直にコピーアンドペーストされました 。 テストでは、4つのスレッドを使用します(メインスレッドを考慮)。 完成版はこちらにあります



主なタスクを分離すると、少し頭痛がします。 したがって、たとえば、図がグリッド内のセルの境界を越える場合、同時に複数のセルになります。



画像






ここでは、図aは1つのセルにあり、 bは一度に4つのセルにあります。 したがって、これらのセルへのアクセスは同期する必要があります。 また、 Shape



クラスの一部のフィールドへのアクセスを同期する必要があります。 これを行うために、 std::mutex



Shape



およびCell



追加しました。



このバージョンを実行すると、30 fpsで13,000の数値を観測できます。 非常に多くのオブジェクトに対して、ウィンドウを大きくしなければなりませんでした! そして再び-各フレームでシミュレーションを20回繰り返します。



画像



2.配列の構造



2.1。 プログラムの最初のバージョン



上で書いたものを、私は伝統的なアプローチと呼んでいます-私は長年このコードを書いており、主に同様のコードを読みました。 しかし、ここでShape



構造を強制終了し、この小さな変更がパフォーマンスに影響するかどうかを確認します。 みんなの喜びに、リファクタリングは複雑ではなく、些細なことでさえありませんでした。 Shape



代わりに、ベクトルを持つ構造を使用します。



Shape.h
 struct ShapesData { std::vector<math::Vec2> positions; std::vector<math::Vec2> velocities; std::vector<math::Vec2> overlapAccumulators; std::vector<float> massesInverses; std::vector<math::Color> colors; std::vector<std::vector<math::Vec2>> vertices; std::vector<math::Bounds> bounds; };
      
      







そして、この構造を次のように渡します。



 solveCollision(struct ShapesData & data, std::size_t const indA, std::size_t const indB);
      
      





つまり 特定の数字の代わりに、それらのインデックスが送信され、メソッドでは必要なデータが必要なベクトルから取得されます。



このバージョンのプログラムは、30 fpsで500個の数値を生成します。 最初のバージョンと違いはありません。 これは、測定が少量のデータで実行され、さらに最も重い方法では構造のほとんどすべてのフィールドが使用されるためです。



さらに写真なしで、 それらは以前とまったく同じです。



2.2。 最適化番号1.空間グリッド



すべては以前と同じで、 AoSのみをSoAに変更します 。 コードはこちらです。 結果は以前よりも優れています-9500の数字(8000がありました)、つまり パフォーマンスの違いは約15%です。



2.3。 最適化番号2.マルチスレッド



繰り返しますが、古いコードを取り、構造を変更して、30 fpsで15,000の数値を取得します。 つまり 約15%のパフォーマンスの向上。 最終バージョンのソースコードはこちらです。



3.結論



当初、コードは、さまざまなアプローチ、そのパフォーマンス、および利便性をテストするために自分用に作成されました。 結果が示したように、コードを少し変更すると、かなり具体的な増加が見られます。 または、そうでない場合もあり、逆の場合もあります-生産性が悪化します。 たとえば、必要なインスタンスが1つだけの場合、標準的なアプローチを使用すると、メモリから1回だけ読み取り、すべてのフィールドにアクセスできます。 同じベクトル構造を使用して、各リクエストでキャッシュミスを使用して、各フィールドを個別にクエリする必要があります。 さらに、可読性がわずかに悪化し、コードがより複雑になります。



したがって、誰にとっても新しいパラダイムに移行する価値があるかどうかを明確に答えることは不可能です。 ゲームエンジンのゲーム開発者で働いたとき、生産性が10%向上したことは印象的な数字です。 ランチャーなどのユーザーユーティリティを作成したとき、 DODアプローチを適用しても同僚が困惑するだけでした。 一般に、自分で結論をプロファイリング、測定、および描画します:)。



All Articles