Come beneficiare della cache della CPU in un motore di gioco del sistema di componenti di entità?


15

Leggo spesso nelle documentazioni del motore di gioco ECS che è una buona architettura per usare saggiamente la cache della CPU.

Ma non riesco a capire come possiamo beneficiare della cache della CPU.

Se i componenti vengono salvati in un array (o in un pool), nella memoria contigua, è un buon modo di usare la cache della CPU MA solo se leggiamo i componenti in sequenza.

Quando usiamo i sistemi, hanno bisogno di un elenco di entità che sono un elenco di entità che hanno componenti con tipi specifici.

Ma questi elenchi forniscono i componenti in modo casuale, non in sequenza.

Quindi, come progettare un ECS per massimizzare l'hit della cache?

MODIFICARE :

Ad esempio, un sistema fisico necessita di un elenco di entità per entità con i componenti RigidBody e Transform (esiste un pool per RigidBody e un pool per i componenti Transform).

Quindi il suo ciclo per l'aggiornamento delle entità sarà così:

for (Entity eid in entitiesList) {
    // Get rigid body component
    RigidBody *rigidBody = entityManager.getComponentFromEntity<RigidBody>(eid);

    // Get transform component
    Transform *transform = entityManager.getComponentFromEntity<Transform>(eid);

    // Do something with rigid body and transform component
}

Il problema è che il componente RigidBody dell'entità1 può trovarsi nell'indice 2 del suo pool e il componente Tranform dell'entità1 nell'indice 0 del suo pool (perché alcune entità possono avere alcuni componenti e non l'altro e a causa dell'aggiunta / eliminazione di entità / componenti casualmente).

Quindi, anche se i componenti sono contigui in memoria, vengono letti in modo casuale e quindi avrà più cache miss, no?

A meno che non ci sia un modo per precaricare i componenti successivi nel ciclo?


puoi mostrarci come stai allocando ogni componente?
concept3d

Con un semplice allocatore di pool e un gestore handle per avere riferimenti ai componenti per gestire il trasferimento dei componenti nel pool (per mantenere i componenti contigui nella memoria).
Johnmph,

Il ciclo di esempio presuppone che gli aggiornamenti dei componenti siano interfogliati per entità. In molti casi è possibile aggiornare i componenti in blocco per tipo di componente (ad es. Aggiornare prima tutti i componenti del corpo rigido, quindi aggiornare tutte le trasformazioni con i dati del corpo rigido finiti, quindi aggiornare tutti i dati di rendering con le nuove trasformazioni ...) - questo può migliorare la cache utilizzare per ogni aggiornamento del componente. Penso che questo tipo di struttura sia ciò che Nick Wiggill suggerisce di seguito.
DMGregory

È il mio esempio che è negativo, infatti, è più il sistema "aggiorna tutte le trasformazioni con i dati del corpo rigido finito" che il sistema fisico. Ma il problema rimane lo stesso, in questi sistemi (aggiornamento della trasformazione con corpo rigido, aggiornamento del rendering con trasformazione, ...), avremo bisogno di più di un tipo di componente contemporaneamente.
Johnmph,

Non sei sicuro che anche questo possa essere rilevante? gamasutra.com/view/feature/6345/…
DMGregory

Risposte:


13

L'articolo di Mick West spiega integralmente il processo di linearizzazione dei dati dei componenti delle entità. Ha funzionato per la serie Tony Hawk, anni fa, su hardware molto meno impressionante di quello che abbiamo oggi, per migliorare notevolmente le prestazioni. In pratica ha usato array globali pre-allocati per ciascun tipo distinto di dati di entità (posizione, punteggio e quant'altro) e fa riferimento a ciascun array in una fase distinta della sua update()funzione a livello di sistema. Si può presumere che i dati per ciascuna entità siano nello stesso indice di array in ciascuno di questi array globali, quindi, per esempio, se il player viene creato per primo, potrebbe avere i suoi dati [0]in ogni array.

Ancora più specifici per l'ottimizzazione della cache, le slide di Christer Ericsson per C e C ++.

Per dare un po 'più di dettaglio, dovresti provare a usare blocchi di memoria contigui (più facilmente allocati come array) per ogni tipo di dati (ad es. Posizione, xy e z), per garantire una buona località di riferimento, utilizzando ciascuno di questi blocchi di dati in distinti update()fasi per il bene della località temporale, ad esempio per garantire che la cache non venga scaricata tramite l'algoritmo LRU dell'hardware prima di riutilizzare tutti i dati che si intende riutilizzare, all'interno di una determinata update()chiamata. Come hai sottinteso, ciò che non vuoi fare è allocare le entità e i componenti come oggetti discreti tramite new, poiché i dati di tipi diversi su ciascuna istanza dell'entità verranno quindi interlacciati, riducendo la località di riferimento.

Se si hanno interdipendenze tra componenti (dati) tali da non poter assolutamente permettersi di separare alcuni dati dai dati associati (ad es. Transform + Physics, Transform + Renderer), è possibile scegliere di replicare Transform data sia negli array Physics che in Renderer , assicurando che tutti i dati pertinenti si adattano alla larghezza della linea della cache per ogni operazione critica per le prestazioni.

Ricorda anche che la cache L2 e L3 (se puoi assumerle per la tua piattaforma di destinazione) fanno molto per alleviare i problemi che possono subire la cache L1, come una larghezza della linea restrittiva. Quindi, anche in mancanza di L1, si tratta di reti di sicurezza che spesso impediranno callout alla memoria principale, che è ordini di grandezza più lenti rispetto ai callout a qualsiasi livello di cache.

Nota sulla scrittura dei dati La scrittura non richiama la memoria principale. Per impostazione predefinita, i sistemi odierni hanno abilitato la memorizzazione nella cache del write-back : la scrittura di un valore lo scrive solo nella cache (inizialmente), non nella memoria principale, quindi non sarete colli di bottiglia da questo. È solo quando i dati vengono richiesti dalla memoria principale (non accadrà mentre è nella cache) ed è obsoleto, quella memoria principale verrà aggiornata dalla cache.


1
Solo una nota per chiunque potrebbe essere nuovo a C ++: std::vectorè fondamentalmente un array ridimensionabile dinamicamente e quindi è anche contiguo (di fatto nelle versioni C ++ precedenti e de jure nelle versioni C ++ più recenti). Alcune implementazioni std::dequesono anche "abbastanza contigue" (anche se non di Microsoft).
Sean Middleditch il

2
@Johnmph Molto semplicemente: se non hai località di riferimento, non hai nulla. Se due parti di dati sono strettamente correlate (come le informazioni spaziali e fisiche), ovvero vengono elaborate insieme, potrebbe essere necessario compattarle come un singolo componente, intercalate. Ma tieni presente quindi che qualsiasi altra logica (diciamo, AI) che sfrutta quei dati spaziali può quindi soffrire a causa dei dati spaziali che non vengono inclusi al suo fianco . Quindi dipende da ciò che richiede il massimo delle prestazioni (forse la fisica nel tuo caso). Ha senso?
Ingegnere il

1
@Johnmph sì, sono pienamente d'accordo con Nick, riguarda il modo in cui sono archiviati in memoria, se hai un'entità con puntatori a due componenti che sono lontani nella memoria, non hai una località, devono adattarsi a una riga della cache.
concept3d

2
@Johnmph: In effetti, l'articolo di Mick West presuppone interdipendenze minime. Quindi: ridurre al minimo le dipendenze; Dati Replica lungo le linee di cache dove non si può ridurre al minimo tali dipendenze ... ad esempio, includono Transform fianco sia corpo rigido e rendering; e per adattarsi alle linee della cache, potrebbe essere necessario ridurre il più possibile gli atomi di dati ... ciò potrebbe essere ottenuto in parte passando da un punto mobile a un punto fisso (4 byte contro 2 byte) per valore decimale. Ma in un modo o nell'altro, indipendentemente da come lo fai, i tuoi dati devono adattarsi alla larghezza della linea della cache, come notato da concept3d, per ottenere le massime prestazioni.
Ingegnere il

2
@Johnmph. No. Ogni volta che scrivi Trasforma i dati, semplicemente li scrivi su entrambi gli array. Non sono quelle scritture di cui devi preoccuparti. Dopo aver inviato una scrittura, è buono come fatto. Sono le letture , più avanti nell'aggiornamento, quando si esegue Physics and Renderer, che devono avere accesso a tutti i dati pertinenti, immediatamente, in una singola riga della cache da vicino e personale alla CPU. Inoltre, se hai davvero bisogno di tutto insieme, allora fai ulteriori repliche o ti assicuri che la fisica, la trasformazione e il rendering si adattino a una singola riga della cache ... 64 byte è comune ed è in realtà un sacco di dati! ...
Ingegnere il
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.