エンティティシステムのキャッシュミスと使いやすさ


18

最近、私は自分のフレームワークのためにエンティティシステムを調査し、実装しています。私は見つけることができるほとんどの記事、reddits、およびそれについての質問を読んだと思います。

ただし、全体的なC ++の動作、エンティティシステムを実装する言語、およびユーザビリティの問題についていくつかの疑問が生じました。

したがって、1つのアプローチは、エンティティにコンポーネントの配列を直接格納することです。これは、データを反復処理するときにキャッシュの局所性を損なうため、実行しませんでした。このため、コンポーネントタイプごとに1つの配列を作成することにしました。そのため、同じタイプのすべてのコンポーネントがメモリ内で連続しており、迅速な反復に最適なソリューションになります。

しかし、実際のゲームプレイ実装のシステムからコンポーネント配列を繰り返し処理する場合、ほとんどの場合、一度に2つ以上のコンポーネントタイプを使用しています。たとえば、レンダリングシステムはTransformとModelコンポーネントを一緒に使用して、実際にレンダリング呼び出しを行います。私の質問は、これらのケースでは一度に1つの連続した配列を直線的に反復していないので、この方法でコンポーネントを割り当てることによるパフォーマンスの向上をすぐに犠牲にするのですか?C ++で2つの異なる連続した配列を繰り返し、各サイクルで両方のデータを使用する場合、問題がありますか?

私が質問したい別のことは、コンポーネントまたはエンティティへの参照をどのように保持する必要があるかです。コンポーネントがメモリに配置される方法の性質は、配列内の位置を簡単に切り替えることができるか、配列が拡張またはコンポーネントポインターまたはハンドルが無効のままになります。フレームごとに変換や他のコンポーネントを操作したいことがよくあり、ハンドルまたはポインターが無効な場合は、フレームごとに検索するのが非常に面倒なので、これらのケースをどのように処理することをお勧めしますか?


4
コンポーネントを連続メモリに配置するのではなく、各コンポーネントにメモリを動的に割り当てるだけです。いずれにしても、かなりランダムな順序でコンポーネントにアクセスする可能性が高いため、連続したメモリによってキャッシュパフォーマンスが向上することはほとんどありません。
JarkkoL 14

@Grimshaw読むべき興味深い記事を次に示します。azarious.cat
v.org

@JarkkoL -10ポイント。システムキャッシュフレンドリーを構築し、ランダムな方法でアクセスすると、パフォーマンスが本当に低下します。音だけで愚かです。直線的にアクセスすることがポイントです。ECSの技術とパフォーマンスの向上は、直線的にアクセスされるC / Sを記述することです。
ウォンドラ14

@Grimshawは、キャッシュが1整数より大きいことを忘れないでください。いくつかのKBのL1キャッシュ(およびその他のMB)を使用できます。怪しげなことを何もしなければ、一度にいくつかのシステムにアクセスし、キャッシュフレンドリーでも問題ありません。
ウォンドラ14

2
@wondraコンポーネントへの線形アクセスをどのように確保しますか?レンダリング用のコンポーネントを収集し、エンティティをカメラから降順で処理する場合を考えてみましょう。これらのエンティティのレンダリングコンポーネントは、メモリ内で直線的にアクセスされることはありません。あなたの言うことは理論的には良いことですが、実際に機能しているとは思いませんが、間違っていることを証明してくれてうれしいです::
JarkkoL

回答:


13

まず、このケースでは、ユースケースに応じて最適化が早すぎるとは言いません。いずれにせよ、あなたは面白い質問をしてきました。私自身もこれを経験しているので、私は量り込みます。

  • 各エンティティは、あらゆるタイプを表すことができる汎用コンポーネントハンドルのベクトルを保持します。
  • 各コンポーネントハンドルを逆参照して、生のT *ポインターを生成できます。*下記参照。
  • 各コンポーネントタイプには、独自のプール、連続したメモリブロックがあります(私の場合は固定サイズ)。

いいえ、常にコンポーネントプールを横断して、理想的なクリーンなことを行うことはできないことに注意してください。既に述べたように、コンポーネント間に避けられないリンクがあり、エンティティを一度に処理する必要があります。

ただし、実際に特定のコンポーネントタイプのforループを記述し、CPUキャッシュラインを最大限に活用できる場合があります(私が発見したように)。知らない、またはもっと知りたい人は、https://en.wikipedia.org/wiki/Locality_of_referenceをご覧ください。同じメモで、可能であれば、コンポーネントのサイズをCPUキャッシュラインサイズ以下に保つようにしてください。私の行サイズは64バイトでしたが、これは一般的だと思います。

私の場合、システムを実装する努力をする価値は十分にありました。目に見えるパフォーマンスの向上が見られました(もちろんプロファイル)。良いアイデアかどうかを自分で決める必要があります。1000以上のエンティティで見た最大のパフォーマンス向上。

私が質問したい別のことは、コンポーネントまたはエンティティへの参照をどのように保持する必要があるかです。コンポーネントがメモリに配置される方法の性質は、配列内の位置を簡単に切り替えることができるか、配列が拡張またはコンポーネントポインターまたはハンドルが無効のままになります。フレームごとに変換や他のコンポーネントを操作したいことがよくあり、ハンドルまたはポインターが無効な場合は、フレームごとに検索するのが非常に面倒なので、これらのケースをどのように処理することをお勧めしますか?

私もこの問題を個人的に解決しました。私は次のようなシステムになりました:

  • 各コンポーネントハンドルは、プールインデックスへの参照を保持します
  • コンポーネントがプールから「削除」または「削除」されると、そのプール内の最後のコンポーネントが(文字通りstd :: moveを使用して)空きの場所に移動されます。最後のコンポーネントを削除した場合は何も移動されません。
  • 「スワップ」が発生すると、リスナーに通知するコールバックがあるため、具体的なポインター(T *など)を更新できます。

*私が扱っていたエンティティの数で、使用頻度の高いコードの特定のセクションで実行時にコンポーネントハンドルを常に間接参照しようとすると、パフォーマンスの問題になることがわかりました。そのため、現在、プロジェクトのパフォーマンスに重要な部分で生のTポインターを維持していますが、そうでない場合は、可能な場合に使用する必要がある汎用コンポーネントハンドルを使用しています。上記のように、コールバックシステムを使用して、それらを有効に保ちます。そこまで行く必要はないかもしれません。

何よりも、試してみてください。現実世界のシナリオが得られるまで、ここで誰かが言うことは、物事を行うための1つの方法にすぎません。

それは役立ちますか?不明な点はすべて明確にしようとします。修正も歓迎します。


賛成で、これは本当に良い答えであり、それは特効薬ではないかもしれませんが、誰かが同様のデザインのアイデアを持っているのを見るのはまだ良いです。ESにもいくつかのトリックが実装されており、実用的なようです。どうもありがとう!アイデアがあれば、気軽にコメントしてください。
グリムショー14

5

これに答えるには:

私の質問は、これらのケースでは一度に1つの連続した配列を直線的に反復していないので、この方法でコンポーネントを割り当てることによるパフォーマンスの向上をすぐに犠牲にするのですか?C ++で2つの異なる連続した配列を繰り返し、各サイクルで両方のデータを使用する場合、問題がありますか?

いいえ(少なくとも必ずしもそうではありません)。ほとんどの場合、キャッシュコントローラーは、複数の連続した配列からの読み取りを効率的に処理できる必要があります。重要な部分は、各配列に直線的にアクセスするために可能な限り試すことです。

これを実証するために、小さなベンチマークを作成しました(通常のベンチマークの注意事項が適用されます)。

単純なベクトル構造体から始めます。

struct float3 { float x, y, z; };

2つの別々の配列の各要素を合計し、結果を3番目に格納するループは、ソースデータが1つの配列にインターリーブされ、結果が3番目に格納されるバージョンとまったく同じように実行されることがわかりました。ただし、結果をソースとインターリーブすると、パフォーマンスが低下します(約2倍)。

データにランダムにアクセスすると、パフォーマンスは10〜20倍低下しました。

タイミング(10,000,000要素)

線形アクセス

  • 個別の配列0.21s
  • インターリーブされたソース0.21s
  • インターリーブされたソースと結果0.48s

ランダムアクセス(random_shuffleのコメントを外す)

  • 個別のアレイ2.42
  • インターリーブされたソース4.43s
  • インターリーブされたソースと結果4.00

ソース(Visual Studio 2013でコンパイル):

#include <Windows.h>
#include <vector>
#include <algorithm>
#include <iostream>

struct float3 { float x, y, z; };

float3 operator+( float3 const &a, float3 const &b )
{
    return float3{ a.x + b.x, a.y + b.y, a.z + b.z };
}

struct Both { float3 a, b; };

struct All { float3 a, b, res; };


// A version without any indirection
void sum( float3 *a, float3 *b, float3 *res, int n )
{
    for( int i = 0; i < n; ++i )
        *res++ = *a++ + *b++;
}

void sum( float3 *a, float3 *b, float3 *res, int *index, int n )
{
    for( int i = 0; i < n; ++i, ++index )
        res[*index] = a[*index] + b[*index];
}

void sum( Both *both, float3 *res, int *index, int n )
{
    for( int i = 0; i < n; ++i, ++index )
        res[*index] = both[*index].a + both[*index].b;
}

void sum( All *all, int *index, int n )
{
    for( int i = 0; i < n; ++i, ++index )
        all[*index].res = all[*index].a + all[*index].b;
}

class PerformanceTimer
{
public:
    PerformanceTimer() { QueryPerformanceCounter( &start ); }
    double time()
    {
        LARGE_INTEGER now, freq;
        QueryPerformanceCounter( &now );
        QueryPerformanceFrequency( &freq );
        return double( now.QuadPart - start.QuadPart ) / double( freq.QuadPart );
    }
private:
    LARGE_INTEGER start;
};

int main( int argc, char* argv[] )
{
    const int count = 10000000;

    std::vector< float3 > a( count, float3{ 1.f, 2.f, 3.f } );
    std::vector< float3 > b( count, float3{ 1.f, 2.f, 3.f } );
    std::vector< float3 > res( count );

    std::vector< All > all( count, All{ { 1.f, 2.f, 3.f }, { 1.f, 2.f, 3.f }, { 1.f, 2.f, 3.f } } );
    std::vector< Both > both( count, Both{ { 1.f, 2.f, 3.f }, { 1.f, 2.f, 3.f } } );

    std::vector< int > index( count );
    int n = 0;
    std::generate( index.begin(), index.end(), [&]{ return n++; } );
    //std::random_shuffle( index.begin(), index.end() );

    PerformanceTimer timer;
    // uncomment version to test
    //sum( &a[0], &b[0], &res[0], &index[0], count );
    //sum( &both[0], &res[0], &index[0], count );
    //sum( &all[0], &index[0], count );
    std::cout << timer.time();
    return 0;
}

1
これは、キャッシュの局所性についての私の疑問に大いに役立ちます、ありがとう!
グリムショー14

シンプルで面白い答えも安心です:)アイテム数が異なるとこれらの結果がどのように変化するか(つまり、10,000,000ではなく1000?)、またはより多くの値の配列がある場合(つまり、3の要素を合計する) -5個の個別の配列と、別の個別の配列に値を保存します)。
Awesomania

2

短い答え: プロファイルしてから最適化します。

長い答え:

しかし、実際のゲームプレイ実装のシステムからコンポーネント配列を繰り返し処理する場合、ほとんどの場合、一度に2つ以上のコンポーネントタイプを使用しています。

C ++で2つの異なる連続した配列を繰り返し、各サイクルで両方のデータを使用する場合、問題がありますか?

C ++は、あらゆるプログラミング言語に適用されるため、キャッシュミスには責任を負いません。これは、最新のCPUアーキテクチャの機能に関係しています。

あなたの問題は、早すぎる最適化と呼ばれるものの良い例かもしれません。

私の意見では、プログラムのメモリアクセスパターンを見ずにキャッシュの局所性を最適化するのは早すぎます。しかし、もっと大きな疑問は、この種の(参照の局所性)最適化が本当に必要なのかということです。

Agner's Fogは、アプリケーションのプロファイルを作成する前に最適化すべきでないこと、および/またはボトルネックがどこにあるかを確実に知ることを推奨します。(これはすべて、彼の優れたガイドに記載されています。以下のリンク)

ノンシーケンシャルアクセスのビッグデータ構造を持つプログラムを作成していて、キャッシュの競合を防ぎたい場合は、キャッシュがどのように構成されているかを知っておくと役立ちます。よりヒューリスティックなガイドラインに満足している場合は、このセクションをスキップできます。

残念ながら、実際にアレイごとに1つのコンポーネントタイプを割り当てることでパフォーマンスが向上すると想定していましたが、実際にはより多くのキャッシュミスやキャッシュ競合が発生した可能性があります。

彼の優れたC ++最適化ガイドをぜひご覧ください

もう1つ質問したいのは、コンポーネントがメモリにどのように配置されているかという性質から、コンポーネントまたはエンティティへの参照をどのように保持するかです。

個人的には、ほとんどの使用済みコンポーネントを単一のメモリブロックにまとめて割り当てるため、「近くの」アドレスを持っています。たとえば、配列は次のようになります。

[{ID0 Transform Model PhysicsComp }{ID10 Transform Model PhysicsComp }{ID2 Transform Model PhysicsComp }..] パフォーマンスが「十分」でない場合は、そこから最適化を開始します。


私の質問は、私のアーキテクチャがパフォーマンスに与える影響についてでした。ポイントは最適化ではなく、内部的に物事を整理する方法を選択することでした。内部で起こっている方法に関係なく、後で変更したい場合に備えて、ゲームコードが同種の方法で対話するようにします。データの保存方法に関する追加の提案を提供できたとしても、あなたの答えは良かったです。賛成。
グリムショー14

私が見るものから、コンポーネントを格納する3つの主な方法があり、すべてエンティティごとに単一の配列に結合され、すべて個別の配列にタイプ別に結合されます。エンティティごとに、すべてのコンポーネントが一緒になっていますか?
グリムショー14

@Grimshaw答えで述べたように、あなたのアーキテクチャは通常の割り当てパターンよりも良い結果を出すことを保証されていません。あなたのアプリケーションのアクセスパターンを本当に知らないので。このような最適化は、通常、ある程度の調査/証拠の後に行われます。私の提案に関しては、関連するコンポーネントを同じメモリに、他のコンポーネントを異なる場所に一緒に保存します。これは、すべてまたはゼロの中間です。それでも、いくつの条件が作用するかを考えると、あなたのアーキテクチャが結果にどのように影響するかを予測することはまだ難しいと思います。
concept3d 14

説明するためのダウンボーターのケア?私の答えで問題を指摘してください。より良い、より良い答えを与えます。
concept3d 14

1

私の質問は、これらのケースでは一度に1つの連続した配列を直線的に反復していないので、この方法でコンポーネントを割り当てることによるパフォーマンスの向上をすぐに犠牲にするのですか?

いわゆる「水平」可変サイズブロ​​ック内のエンティティにアタッチされたコンポーネントをインターリーブするよりも、コンポーネントタイプごとに個別の「垂直」配列を使用すると、キャッシュミスが全体的に少なくなる可能性があります。

その理由は、最初に、「垂直」表現はより少ないメモリを使用する傾向があるためです。連続して割り当てられた同種の配列のアライメントについて心配する必要はありません。メモリープールに割り当てられた不均一なタイプでは、配列の最初の要素のサイズと配列の要件が2番目の要素とはまったく異なる可能性があるため、配列について心配する必要があります。そのため、単純な例のように、多くの場合、パディングを追加する必要があります。

// Assuming 8-bit chars and 64-bit doubles.
struct Foo
{
    // 1 byte
    char a;

    // 1 byte
    char b;
};

struct Bar
{
    // 8 bytes
    double opacity;

    // 8 bytes
    double radius;
};

インターリーブFooBar、メモリ内で互いに隣り合わせに保存したいとしましょう:

// Assuming 8-bit chars and 64-bit doubles.
struct FooBar
{
    // 1 byte
    char a;

    // 1 byte
    char b;

    // 6 bytes padding for 64-bit alignment of 'opacity'

    // 8 bytes
    double opacity;

    // 8 bytes
    double radius;
};

FooとBarを別々のメモリ領域に保存するのに18バイトかかる代わりに、それらを融合するのに24バイトかかります。順序を入れ替えてもかまいません:

// Assuming 8-bit chars and 64-bit doubles.
struct BarFoo
{
    // 8 bytes
    double opacity;

    // 8 bytes
    double radius;

    // 1 byte
    char a;

    // 1 byte
    char b;

    // 6 bytes padding for 64-bit alignment of 'opacity'
};

アクセスパターンを大幅に改善せずにシーケンシャルアクセスコンテキストでより多くのメモリを使用すると、一般にキャッシュミスが発生します。その上、あるエンティティから次のエンティティに到達するまでの歩幅が増加し、可変サイズになるため、メモリ内で可変サイズのジャンプを実行して、あるエンティティから次のエンティティに到達するコンポーネントを確認する必要があります再興味

したがって、コンポーネントタイプを格納するときに「垂直」表現を使用することは、実際には「水平」代替よりも最適である可能性が高くなります。とはいえ、ここでは、垂直表現のキャッシュミスの問題を例示できます。

ここに画像の説明を入力してください

矢印は、エンティティがコンポーネントを「所有」していることを示しています。両方を持つエンティティのすべてのモーションコンポーネントとレンダリングコンポーネントにアクセスしようとすると、メモリ内のすべての場所にジャンプしてしまうことがわかります。この種の散発的なアクセスパターンでは、たとえばモーションコンポーネントにアクセスするためにデータをキャッシュラインにロードし、その後、より多くのコンポーネントにアクセスし、以前のデータを削除することができます。成分。そのため、コンポーネントのリストをループしてアクセスするためだけに、まったく同じメモリ領域をキャッシュラインに複数回ロードすることは非常に無駄です。

この混乱を少し整理して、より明確に表示できるようにします。

ここに画像の説明を入力してください

この種のシナリオが発生した場合、通常はゲームの実行が開始されてから、多くのコンポーネントとエンティティが追加および削除されてから長い時間がかかることに注意してください。一般に、ゲームの開始時には、すべてのエンティティと関連するコンポーネントを一緒に追加します。その時点で、それらは非常に規則正しいシーケンシャルアクセスパターンを持ち、空間的な局所性が優れています。ただし、多くの削除と挿入を行った後、上記の混乱のような結果になる可能性があります。

この状況を改善する非常に簡単な方法は、コンポーネントを所有するエンティティID /インデックスに基づいてコンポーネントを基数で並べ替えるだけです。その時点で、次のようになります。

ここに画像の説明を入力してください

そして、それははるかにキャッシュフレンドリーなアクセスパターンです。私たちのシステムは両方を持つエンティティのみに関心があり、一部のエンティティはモーションコンポーネントのみを持ち、一部はレンダリングコンポーネントのみを持つため、あちこちでいくつかのレンダリングコンポーネントとモーションコンポーネントをスキップする必要があることがわかるため、完璧ではありませんしかし、少なくともいくつかの連続するコンポーネントを処理できるようになります(実際には、システム内のモーションコンポーネントを持つエンティティがレンダリングコンポーネントを持つより多くのエンティティのように、関連する関連コンポーネントをアタッチすることが多いため、通常、ない)。

最も重要なことは、これらを並べ替えた後、メモリ領域のデータをキャッシュラインにロードしてから、単一のループでデータをリロードすることではありません。

そして、これはいくつかの非常に複雑な設計を必要とせず、単に線形時間基数ソートパスが時々あり、特定のコンポーネントタイプの一連のコンポーネントを挿入および削除した後、その時点でマークすることができますソートする必要があります。合理的に実装された基数の並べ替え(並列化することもできますが、これを行うこともできます)は、次に示すように、クアッドコアi7で約6ミリ秒で100万個の要素を並べ替えることができます。

Sorting 1000000 elements 32 times...
mt_sort_int: {0.203000 secs}
-- small result: [ 22 48 59 77 79 80 84 84 93 98 ]
mt_sort: {1.248000 secs}
-- small result: [ 22 48 59 77 79 80 84 84 93 98 ]
mt_radix_sort: {0.202000 secs}
-- small result: [ 22 48 59 77 79 80 84 84 93 98 ]
std::sort: {1.810000 secs}
-- small result: [ 22 48 59 77 79 80 84 84 93 98 ]
qsort: {2.777000 secs}
-- small result: [ 22 48 59 77 79 80 84 84 93 98 ]

上記では、100万個の要素を32回並べ替えます(memcpy並べ替え前後の結果までの時間を含む)。そして、ほとんどの場合、実際には100万以上のコンポーネントを並べ替えることはないと想定しているため、目立ったフレームレートのスタッターを発生させることなく、非常に簡単にこっそりとこっそりとこなせるはずです。

弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.