Mancanze della cache e usabilità nei sistemi di entità


18

Ultimamente ho ricercato e implementato un sistema di entità per la mia struttura. Penso di aver letto la maggior parte degli articoli, dei rossetti e delle domande che potrei trovare, e finora penso di capire abbastanza bene l'idea.

Tuttavia, ha sollevato alcune domande sul comportamento generale del C ++, sul linguaggio in cui implemento il sistema di entità, nonché su alcuni problemi di usabilità.

Quindi, un approccio sarebbe quello di archiviare direttamente una matrice di componenti nell'entità, cosa che non ho fatto perché rovina la località della cache quando scorre i dati. Per questo motivo, ho deciso di avere un array per tipo di componente, quindi tutti i componenti dello stesso tipo sono contigui in memoria, il che dovrebbe essere la soluzione ottimale per una rapida iterazione.

Ma, quando devo iterare array di componenti per fare qualcosa con loro da un sistema su un'implementazione di gioco effettiva, noto che quasi sempre lavoro con due o più tipi di componenti contemporaneamente. Ad esempio, il sistema di rendering utilizza insieme il componente Trasforma e Modello per effettuare effettivamente una chiamata di rendering. La mia domanda è, poiché in questi casi non sto ripetendo linearmente un array contiguo alla volta, sto sacrificando immediatamente i guadagni in termini di prestazioni allocando i componenti in questo modo? È un problema quando eseguo l'iterazione, in C ++, di due array contigui diversi e utilizzo i dati di entrambi in ciascun ciclo?

Un'altra cosa che volevo chiedere è come si dovrebbero conservare i riferimenti a componenti o entità, poiché la natura stessa di come i componenti sono posti in memoria, possono facilmente cambiare posizione nell'array o l'array può essere riallocato per espandersi o si restringe, lasciando invalidi i puntatori o le maniglie dei miei componenti. Come mi consigliate di gestire questi casi, poiché mi trovo spesso a voler operare su trasformazioni e altri componenti in ogni frame e se i miei handle o puntatori non sono validi, è piuttosto complicato fare ricerche in ogni frame.


4
Non mi preoccuperei di mettere i componenti in una memoria continua, ma allocarei memoria per ciascun componente in modo dinamico. È improbabile che la memoria contigua ti dia alcun vantaggio in termini di prestazioni della cache perché è probabile che tu acceda comunque ai componenti in un ordine abbastanza casuale.
JarkkoL,

@Grimshaw Ecco un articolo interessante da leggere: harmful.cat-v.org/software/OO_programming/_pdf/...
Raxvan

@JarkkoL -10 punti. Danneggia davvero le prestazioni se si crea una cache di sistema compatibile e si accede ad esso in modo casuale , è stupido solo dal suono di esso. Il punto per accedervi in modo lineare . L'arte dell'ECS e il miglioramento delle prestazioni riguardano la scrittura di C / S a cui si accede in modo lineare.
Wondra,

@Grimshaw non dimenticare che la cache è più grande di un intero. Hai diversi KB di cache L1 disponibili (e MB di altri), se non fai nulla di mostruoso, dovresti OK per accedere a pochi sistemi contemporaneamente e pur essendo compatibile con la cache.
Wondra,

2
@wondra Come assicureresti l'accesso lineare ai componenti? Supponiamo che raccolgo componenti per il rendering e che le entità vengano elaborate in ordine decrescente dalla fotocamera. I componenti di rendering per queste entità non saranno accessibili linearmente in memoria. Mentre quello che dici è una buona cosa in teoria, non lo vedo funzionare in pratica, ma sono contento se mi dimostri che mi sbaglio (:
JarkkoL

Risposte:


13

Innanzitutto, non direi che in questo caso stai ottimizzando troppo presto, a seconda del tuo caso d'uso. In ogni caso, comunque, hai fatto una domanda interessante e, dato che ho esperienza con questo, peserò. Cercherò di spiegare come ho finito per fare le cose e cosa ho trovato sulla strada.

  • Ogni entità contiene un vettore di handle di componenti generici che possono rappresentare qualsiasi tipo.
  • Ogni handle di componente può essere dedotto per produrre un puntatore T * non elaborato. *Vedi sotto.
  • Ogni tipo di componente ha il suo pool, un blocco continuo di memoria (dimensione fissa nel mio caso).

Va notato che no, non sarai solo in grado di attraversare sempre un pool di componenti e fare la cosa ideale e pulita. Ci sono, come hai detto, collegamenti inevitabili tra i componenti, in cui è davvero necessario elaborare le cose un'entità alla volta.

Tuttavia, ci sono casi (come ho scoperto) in cui, in effetti, puoi letteralmente scrivere un ciclo for per un particolare tipo di componente e fare un grande uso delle tue linee di cache della CPU. Per coloro che non sono a conoscenza o desiderano saperne di più, dai un'occhiata a https://en.wikipedia.org/wiki/Locality_of_reference . Sulla stessa nota, quando possibile, prova a mantenere la dimensione del componente inferiore o uguale alla dimensione della linea della cache della CPU. La mia dimensione della linea era di 64 byte, che credo sia comune.

Nel mio caso, vale la pena fare lo sforzo di implementare il sistema. Ho visto miglioramenti delle prestazioni visibili (profilato ovviamente). Dovrai decidere tu stesso se è una buona idea. I maggiori guadagni in termini di prestazioni che ho visto in oltre 1000 entità.

Un'altra cosa che volevo chiedere è come si dovrebbero conservare i riferimenti a componenti o entità, poiché la natura stessa di come i componenti sono posti in memoria, possono facilmente cambiare posizione nell'array o l'array può essere riallocato per espandersi o si restringe, lasciando invalidi i puntatori o le maniglie dei miei componenti. Come mi consigliate di gestire questi casi, poiché mi trovo spesso a voler operare su trasformazioni e altri componenti in ogni frame e se i miei handle o puntatori non sono validi, è piuttosto complicato fare ricerche in ogni frame.

Ho anche risolto personalmente questo problema. Ho finito per avere un sistema in cui:

  • Ogni handle di componente contiene un riferimento a un indice di pool
  • Quando un componente viene 'eliminato' o 'rimosso' da un pool, l'ultimo componente all'interno di quel pool viene spostato (letteralmente con std :: move) nella posizione ora libera, o nessuno se hai appena eliminato l'ultimo componente.
  • Quando si verifica uno 'scambio', ho un callback che avvisa tutti gli ascoltatori, in modo che possano aggiornare eventuali puntatori concreti (es. T *).

* Ho scoperto che cercare di dereferenziare sempre gli handle dei componenti in fase di esecuzione in alcune sezioni di codice di utilizzo elevato con il numero di entità con cui avevo a che fare era un problema di prestazioni. Per questo motivo, mantengo ora alcuni puntatori a T non elaborati nelle parti critiche in termini di prestazioni del mio progetto, ma per il resto utilizzo gli handle generici dei componenti, che dovrebbero essere utilizzati ove possibile. Li mantengo validi come indicato sopra, con il sistema di callback. Potrebbe non essere necessario andare fino in fondo.

Soprattutto, però, basta provare le cose. Fino a quando non ottieni uno scenario del mondo reale, tutto ciò che qualcuno dice qui è solo un modo di fare le cose, che potrebbe non essere appropriato per te.

Questo aiuta? Cercherò di chiarire tutto ciò che non è chiaro. Anche eventuali correzioni sono apprezzate.


Sottoposta a votazione, questa è stata davvero una buona risposta, e anche se potrebbe non essere un proiettile d'argento, è comunque bello vedere qualcuno con idee progettuali simili. Ho implementato anche alcuni dei tuoi trucchi nella mia ES, e sembrano pratici. Molte grazie! Sentiti libero di commentare ulteriori idee se vengono fuori.
Grimshaw,

5

Per rispondere solo a questo:

La mia domanda è, poiché in questi casi non sto ripetendo linearmente un array contiguo alla volta, sto sacrificando immediatamente i guadagni in termini di prestazioni allocando i componenti in questo modo? È un problema quando eseguo l'iterazione, in C ++, di due array contigui diversi e utilizzo i dati di entrambi in ciascun ciclo?

No (almeno non necessariamente). Il controller della cache dovrebbe, nella maggior parte dei casi, essere in grado di gestire in modo efficiente la lettura da più di un array contiguo. La parte importante è provare dove possibile ad accedere a ciascun array in modo lineare.

Per dimostrarlo, ho scritto un piccolo benchmark (si applicano le solite avvertenze sui benchmark).

A partire da una semplice struttura vettoriale:

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

Ho scoperto che un ciclo che sommava ogni elemento di due array separati e memorizzava il risultato in un terzo eseguiva esattamente lo stesso di una versione in cui i dati di origine venivano intercalati in un singolo array e il risultato archiviato in un terzo. Tuttavia, se ho intercalato il risultato con l'origine, ho riscontrato che la performance ha sofferto (di circa un fattore 2).

Se accedessi ai dati in modo casuale, le prestazioni subivano un fattore compreso tra 10 e 20.

Tempi (10.000.000 di elementi)

accesso lineare

  • matrici separate 0,21s
  • sorgente interlacciata 0.21s
  • sorgente interfogliata e risultato 0.48s

accesso casuale (decomment random_shuffle)

  • matrici separate 2.42s
  • sorgente interfogliata 4.43s
  • sorgente interlacciata e risultato 4.00s

Fonte (compilato con 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
Questo aiuta molto con i miei dubbi sulla localizzazione della cache, grazie!
Grimshaw,

Risposta semplice ma interessante che trovo anche rassicurante :) Sarei interessato a vedere come questi risultati variano a seconda del conteggio degli oggetti (cioè 1000 anziché 10.000.000?) O se avessi più matrici di valori (ovvero sommando elementi di 3 -5 array separati e memorizzazione del valore in un altro array separato).
Awesomania,

2

Risposta breve: profilo quindi ottimizzazione.

Risposta lunga:

Ma, quando devo iterare array di componenti per fare qualcosa con loro da un sistema su un'implementazione di gioco effettiva, noto che quasi sempre lavoro con due o più tipi di componenti contemporaneamente.

È un problema quando eseguo l'iterazione, in C ++, di due array contigui diversi e utilizzo i dati di entrambi in ciascun ciclo?

C ++ non è responsabile per i mancati cache, in quanto si applica a qualsiasi linguaggio di programmazione. Ciò ha a che fare con il funzionamento della moderna architettura CPU.

Il tuo problema potrebbe essere un buon esempio di quella che potrebbe essere chiamata ottimizzazione pre-matura .

A mio avviso, hai ottimizzato troppo presto per la localizzazione della cache senza esaminare i modelli di accesso alla memoria del programma. Ma la domanda più grande è: hai davvero bisogno di questo tipo (località di riferimento) di ottimizzazione?

La nebbia di Agner suggerisce che non è necessario ottimizzare prima di creare un profilo dell'applicazione e / o sapere con certezza dove si trovano i colli di bottiglia. (Tutto ciò è menzionato nella sua eccellente guida. Link in basso)

È utile sapere come è organizzata una cache se si stanno realizzando programmi con strutture di big data con accesso non sequenziale e si desidera impedire la contesa della cache. Puoi saltare questa sezione se sei soddisfatto di più linee guida euristiche.

Sfortunatamente, quello che hai fatto è stato supporre che l'allocazione di un tipo di componente per array ti offrirà prestazioni migliori, mentre in realtà potresti aver causato più errori nella cache o persino conflitti di cache.

Dovresti assolutamente dare un'occhiata alla sua eccellente guida all'ottimizzazione C ++ .

Un'altra cosa che volevo chiedere è come si dovrebbero conservare i riferimenti a componenti o entità, poiché la natura stessa di come i componenti vengono depositati in memoria.

Personalmente assegnerò i componenti più usati insieme in un singolo blocco di memoria, quindi hanno indirizzi "vicini". Ad esempio un array apparirà così:

[{ID0 Transform Model PhysicsComp }{ID10 Transform Model PhysicsComp }{ID2 Transform Model PhysicsComp }..] e quindi iniziare a ottimizzare da lì se le prestazioni non erano "abbastanza buone".


La mia domanda riguardava le implicazioni che la mia architettura poteva avere sulle prestazioni, il punto non era di ottimizzare ma di scegliere un modo per organizzare le cose internamente. Indipendentemente dal modo in cui sta accadendo all'interno, voglio che il mio codice di gioco interagisca con esso in modo omogeneo nel caso in cui volessi cambiare in seguito. La tua risposta è stata buona anche se potesse fornire ulteriori suggerimenti su come archiviare i dati. Upvoted.
Grimshaw,

Da quello che vedo, ci sono tre modi principali per conservare i componenti, tutti accoppiati in un singolo array per entità, tutti accoppiati per tipo in singoli array e, se ho capito bene, suggerisci di archiviare diverse Entità contigue in un grande array, e per entità, hanno tutti i suoi componenti insieme?
Grimshaw,

@Grimshaw Come ho detto nella risposta, la tua architettura non garantisce risultati migliori rispetto al normale schema di allocazione. Dal momento che non conosci veramente il modello di accesso delle tue applicazioni. Tali ottimizzazioni vengono generalmente eseguite dopo alcuni studi / prove. Per quanto riguarda il mio suggerimento, conservare i componenti correlati insieme nella stessa memoria e altri componenti in posizioni diverse. Questa è una via di mezzo tra tutto o niente. Tuttavia, presumo ancora che sia difficile prevedere in che modo la tua architettura influenzerà il risultato date le condizioni che entrano in gioco.
concept3d

Il downvoter vuole spiegare? Indica semplicemente il problema nella mia risposta. Meglio ancora dare una risposta migliore.
concept3d

1

La mia domanda è, poiché in questi casi non sto ripetendo linearmente un array contiguo alla volta, sto sacrificando immediatamente i guadagni in termini di prestazioni allocando i componenti in questo modo?

È probabile che nel complesso si verifichino meno errori cache con matrici "verticali" separate per tipo di componente rispetto all'interlacciamento dei componenti collegati a un'entità in un blocco di dimensioni variabili "orizzontale", per così dire.

Il motivo è perché, in primo luogo, la rappresentazione "verticale" tenderà a utilizzare meno memoria. Non devi preoccuparti dell'allineamento per array omogenei assegnati in modo contiguo. Con i tipi non omogenei allocati in un pool di memoria, è necessario preoccuparsi dell'allineamento poiché il primo elemento dell'array potrebbe avere dimensioni e requisiti di allineamento completamente diversi dal secondo. Di conseguenza dovrai spesso aggiungere imbottitura, come ad esempio un semplice esempio:

// 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;
};

Diciamo che vogliamo intercalare Fooe Bararchiviarli uno accanto all'altro in memoria:

// 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;
};

Ora invece di prendere 18 byte per memorizzare Foo e Bar in aree di memoria separate, ci vogliono 24 byte per fonderli. Non importa se si scambia l'ordine:

// 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'
};

Se si prende più memoria in un contesto di accesso sequenziale senza migliorare in modo significativo i modelli di accesso, generalmente si verificheranno più perdite di cache. Inoltre, il passo da passare da un'entità alla successiva aumenta e ad una dimensione variabile, costringendoti a fare salti di dimensioni variabili in memoria per passare da un'entità all'altra solo per vedere quali hanno i componenti che " sei interessato a.

Pertanto, l'utilizzo di una rappresentazione "verticale" durante la memorizzazione dei tipi di componenti ha effettivamente maggiori probabilità di essere ottimale rispetto alle alternative "orizzontali". Detto questo, il problema con la cache manca con la rappresentazione verticale può essere esemplificato qui:

inserisci qui la descrizione dell'immagine

Dove le frecce indicano semplicemente che l'entità "possiede" un componente. Possiamo vedere che se dovessimo provare ad accedere a tutti i componenti di movimento e rendering delle entità che hanno entrambi, finiremmo per saltare dappertutto nella memoria. Quel tipo di modello di accesso sporadico può farti caricare i dati in una riga della cache per accedere, diciamo, a un componente di movimento, quindi accedere a più componenti e far sfrattare quei dati precedenti, solo per caricare nuovamente la stessa area di memoria che è stata già sfrattata per un altro movimento componente. Quindi può essere molto dispendioso caricare le stesse identiche aree di memoria più di una volta in una riga della cache solo per scorrere e accedere a un elenco di componenti.

Puliamo un po 'quel casino in modo da poter vedere più chiaramente:

inserisci qui la descrizione dell'immagine

Nota che se incontri questo tipo di scenario, di solito è molto tempo dopo l'inizio del gioco, dopo che molti componenti ed entità sono stati aggiunti e rimossi. In generale, quando il gioco inizia, potresti aggiungere tutte le entità e i componenti rilevanti insieme, a quel punto potrebbero avere un modello di accesso sequenziale molto ordinato con una buona località spaziale. Dopo un sacco di rimozioni e inserimenti, potresti finire per ottenere qualcosa come il pasticcio sopra.

Un modo molto semplice per migliorare quella situazione è semplicemente ordinare i componenti in modo radicale in base all'ID / indice dell'entità che li possiede. A quel punto ottieni qualcosa del genere:

inserisci qui la descrizione dell'immagine

E questo è un modello di accesso molto più intuitivo. Non è perfetto poiché possiamo vedere che dobbiamo saltare alcuni componenti di rendering e di movimento qua e là poiché il nostro sistema è interessato solo a entità che hanno entrambi , e alcune entità hanno solo una componente di movimento e alcune hanno solo una componente di rendering , ma almeno finisci per essere in grado di elaborare alcuni componenti contigui (più nella pratica, in genere, poiché spesso collegherai componenti rilevanti di interesse, come forse più entità nel tuo sistema che hanno un componente di movimento avranno un componente di rendering di non).

Ancora più importante, una volta ordinati questi, non caricherai i dati di una regione di memoria in una riga della cache solo per ricaricarli in un singolo ciclo.

E questo non richiede un design estremamente complesso, solo un radix-time lineare passa ogni tanto, forse dopo aver inserito e rimosso un gruppo di componenti per un particolare tipo di componente, a quel punto puoi contrassegnarlo come deve essere ordinato. Un ordinamento radix ragionevolmente implementato (puoi persino parallelizzarlo, cosa che faccio) può ordinare un milione di elementi in circa 6ms sul mio quad-core i7, come esemplificato qui:

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 ]

Quanto sopra è di ordinare un milione di elementi 32 volte (incluso il tempo necessario per i memcpyrisultati prima e dopo l'ordinamento). E suppongo che la maggior parte delle volte non avrai effettivamente più di un milione di componenti da ordinare, quindi dovresti essere molto facilmente in grado di intrufolarlo di tanto in tanto senza causare alcun evidente scatti alla frequenza dei fotogrammi.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.