Separazione efficiente delle fasi di lettura / calcolo / scrittura per l'elaborazione simultanea di entità nei sistemi di entità / componente


11

Impostare

Ho un'architettura a componenti di entità in cui le entità possono avere un insieme di attributi (che sono dati puri senza comportamento) e esistono sistemi che eseguono la logica di entità che agiscono su quei dati. In sostanza, in qualche pseudo-codice:

Entity
{
    id;
    map<id_type, Attribute> attributes;
}

System
{
    update();
    vector<Entity> entities;
}

Potrebbe essere un sistema che si sposta semplicemente lungo tutte le entità a un ritmo costante

MovementSystem extends System
{
   update()
   {
      for each entity in entities
        position = entity.attributes["position"];
        position += vec3(1,1,1);
   }
}

In sostanza, sto cercando di parallelizzare update () nel modo più efficiente possibile. Questo può essere fatto eseguendo interi sistemi in parallelo o dando a ciascun aggiornamento () di un sistema un paio di componenti in modo che thread diversi possano eseguire l'aggiornamento dello stesso sistema, ma per un diverso sottoinsieme di entità registrate con quel sistema.

Problema

Nel caso del MovementSystem mostrato, la parallelizzazione è banale. Poiché le entità non dipendono l'una dall'altra e non modificano i dati condivisi, potremmo semplicemente spostare tutte le entità in parallelo.

Tuttavia, a volte questi sistemi richiedono che le entità interagiscano tra loro (leggano / scrivano i dati da / a), a volte all'interno dello stesso sistema, ma spesso tra sistemi diversi che dipendono l'uno dall'altro.

Ad esempio, in un sistema fisico a volte le entità possono interagire tra loro. Due oggetti si scontrano, le loro posizioni, velocità e altri attributi vengono letti da essi, vengono aggiornati e quindi gli attributi aggiornati vengono riscritti in entrambe le entità.

E prima che il sistema di rendering nel motore possa iniziare a renderizzare le entità, deve attendere che altri sistemi completino l'esecuzione per assicurarsi che tutti gli attributi rilevanti siano quelli che devono essere.

Se proviamo a parallelizzare ciecamente questo, porteremo a condizioni di gara classiche in cui diversi sistemi possono leggere e modificare i dati contemporaneamente.

Idealmente, esisterebbe una soluzione in cui tutti i sistemi possano leggere i dati da qualsiasi entità desideri, senza doversi preoccupare di altri sistemi che modificano gli stessi dati contemporaneamente e senza che il programmatore si preoccupi di ordinare correttamente l'esecuzione e la parallelizzazione di questi sistemi manualmente (che a volte potrebbe non essere nemmeno possibile).

In un'implementazione di base, questo potrebbe essere ottenuto semplicemente inserendo tutte le letture e le scritture di dati in sezioni critiche (proteggendole con mutex). Ma ciò induce una grande quantità di sovraccarico di runtime e probabilmente non è adatto per applicazioni sensibili alle prestazioni.

Soluzione?

A mio avviso, una possibile soluzione sarebbe un sistema in cui la lettura / l'aggiornamento e la scrittura dei dati sono separati, in modo che in una fase costosa i sistemi leggano solo i dati e calcolino ciò di cui hanno bisogno per calcolare, in qualche modo memorizzare nella cache i risultati e quindi scrivere tutto i dati modificati ritornano alle entità target in un passaggio di scrittura separato. Tutti i sistemi agirebbero sui dati nello stato in cui si trovavano all'inizio del frame e quindi prima della fine del frame, quando tutti i sistemi hanno terminato l'aggiornamento, si verifica un passaggio di scrittura serializzato in cui i risultati memorizzati nella cache provengono da tutti i diversi i sistemi vengono ripetuti e riscritti nelle entità target.

Questo si basa sull'idea (forse sbagliata?) Che la vincita alla semplice parallelizzazione potrebbe essere abbastanza grande da superare il costo (sia in termini di prestazioni di runtime sia di sovraccarico di codice) della cache dei risultati e del passaggio di scrittura.

La domanda

Come potrebbe essere implementato un tale sistema per ottenere prestazioni ottimali? Quali sono i dettagli di implementazione di un tale sistema e quali sono i prerequisiti per un sistema Entity-Component che desidera utilizzare questa soluzione?

Risposte:


1

----- (basato sulla domanda rivista)

Primo punto: dal momento che non dici di aver profilato il tuo runtime di build di rilascio e di aver trovato un'esigenza specifica, ti suggerisco di farlo al più presto. Che aspetto ha il tuo profilo, stai schiacciando le cache con un layout di memoria scadente, è un core ancorato al 100%, quanto tempo relativo viene impiegato per elaborare l'ECS rispetto al resto del tuo motore, ecc ...

Leggi da un'entità e calcola qualcosa ... e mantieni i risultati da qualche parte in un'area di memorizzazione intermedia fino a dopo? Non penso che tu possa separare read + compute + store nel modo in cui pensi e ti aspetti che questo store intermedio sia tutt'altro che puro sovraccarico.

Inoltre, poiché stai elaborando continuamente la regola principale che vuoi seguire è avere un thread per core della CPU. Penso che tu stia guardando questo al livello sbagliato , prova a guardare interi sistemi e non singole entità.

Crea un grafico delle dipendenze tra i tuoi sistemi, un albero di ciò che il sistema necessita dei risultati del lavoro di un sistema precedente. Una volta che hai quell'albero delle dipendenze, puoi facilmente inviare interi sistemi pieni di entità da elaborare su un thread.

Quindi diciamo che il tuo albero delle dipendenze è una massa di rovi e trappole per orsi, un problema di progettazione ma dobbiamo lavorare con ciò che abbiamo. Il caso migliore qui è che all'interno di ciascun sistema ogni entità non dipende da nessun altro risultato all'interno di quel sistema. Qui puoi facilmente suddividere l'elaborazione tra thread, 0-99 e 100-199 su due thread per un esempio con due core e 200 entità che questo sistema possiede.

In entrambi i casi, in ogni fase devi attendere i risultati da cui dipende la fase successiva. Ma questo va bene perché attendere i risultati di dieci grandi blocchi di dati elaborati in blocco è di gran lunga superiore alla sincronizzazione mille volte per piccoli blocchi.

L'idea alla base della costruzione di un grafico delle dipendenze era di banalizzare il compito apparentemente impossibile di "Trovare e assemblare altri sistemi per funzionare in parallelo" automatizzandolo. Se tale grafico mostra segni di blocco in attesa costante di risultati precedenti, la creazione di una lettura + modifica e una scrittura ritardata sposta solo il blocco e non rimuove la natura seriale dell'elaborazione.

E l'elaborazione seriale può essere attivata solo in parallelo tra ciascun punto della sequenza, ma non nel complesso. Ma te ne rendi conto perché è il nocciolo del tuo problema. Anche se si memorizzano nella cache letture da dati che non sono stati ancora scritti, è comunque necessario attendere che tale cache diventi disponibile.

Se la creazione di architetture parallele fosse facile o addirittura possibile con questo tipo di vincoli, l'informatica non avrebbe dovuto affrontare il problema da Bletchley Park.

L'unica vera soluzione sarebbe quella di ridurre al minimo tutte queste dipendenze per rendere i punti di sequenza il più raramente necessari. Ciò può comportare la suddivisione dei sistemi in fasi di elaborazione sequenziale in cui, all'interno di ciascun sottosistema, andare in parallelo con i thread diventa banale.

Il migliore che ho avuto per questo problema e non è altro che raccomandare che se colpire la testa su un muro di mattoni ti fa male, spezzalo in muri di mattoni più piccoli in modo da colpire solo gli stinchi.


Mi dispiace dirtelo, ma questa risposta sembra un po 'improduttiva. Mi stai solo dicendo che ciò che sto cercando non esiste, il che sembra logicamente sbagliato (almeno in linea di principio) e anche perché ho visto persone alludere a un tale sistema in diversi punti prima (nessuno ne ha mai abbastanza dettagli, tuttavia, che è la motivazione principale per porre questa domanda). Anche se, potrebbe essere possibile che non sia stato abbastanza dettagliato nella mia domanda originale, motivo per cui l'ho ampiamente aggiornato (e continuerò ad aggiornarlo se la mia mente inciampa su qualcosa).
TravisG,

Anche senza offesa: P
TravisG

@TravisG Ci sono spesso sistemi che dipendono da altri sistemi, come ha sottolineato Patrick. Per evitare ritardi di frame o per evitare passaggi multipli di aggiornamento come parte di un passaggio logico, la soluzione accettata è serializzare la fase di aggiornamento, eseguendo sottosistemi in parallelo ove possibile, serializzando sottosistemi con dipendenze per tutto il tempo mentre passano in batch passaggi di aggiornamento più piccoli all'interno di ogni sottosistema che utilizza un concetto parallel_for (). È ideale per qualsiasi combinazione di esigenze di passaggio di aggiornamento del sottosistema e la più flessibile.
Naros,

0

Ho sentito parlare di una soluzione interessante a questo problema: l'idea è che ci sarebbero 2 copie dei dati dell'entità (dispendioso, lo so). Una copia sarebbe la copia presente e l'altra sarebbe la copia passata. La presente copia è rigorosamente di sola scrittura e la copia precedente è di sola lettura. Suppongo che i sistemi non vogliano scrivere sugli stessi elementi di dati, ma in caso contrario, tali sistemi dovrebbero trovarsi sullo stesso thread. Ogni thread avrebbe accesso in scrittura alle copie presenti di sezioni reciprocamente esclusive dei dati, e ogni thread avrebbe accesso in lettura alle copie precedenti dei dati, e quindi potrebbe aggiornare le copie presenti usando i dati delle copie precedenti senza bloccaggio. Tra ogni fotogramma, la copia presente diventa la copia passata, tuttavia si desidera gestire lo scambio di ruoli.

Questo metodo rimuove anche le condizioni di gara perché tutti i sistemi funzioneranno con uno stato stantio che non cambierà prima / dopo che il sistema lo ha elaborato.


Questo è il trucco dell'heap di John Carmack, vero? Mi sono chiesto, ma potenzialmente ha ancora lo stesso problema che più thread potrebbero scrivere nella stessa posizione di output. È probabilmente una buona soluzione se si tiene tutto "single-pass", ma non sono sicuro di quanto sia fattibile.
TravisG,

L'input per la latenza di visualizzazione dello schermo aumenterebbe di 1 frame di tempo, inclusa la reattività della GUI. Che può essere importante per i giochi di azione / cronometraggio o manipolazioni della GUI pesante come RTS. Mi piace come idea creativa, tuttavia.
Patrick Hughes,

Ne ho sentito parlare da un amico e non sapevo che fosse un trucco di Carmack. A seconda di come viene eseguito il rendering, il rendering dei componenti può essere un frame dietro. Potresti semplicemente usarlo per la fase di aggiornamento, quindi eseguire il rendering dalla copia corrente una volta che tutto è aggiornato.
John McDonald,

0

Conosco 3 progetti software che gestiscono l'elaborazione parallela dei dati:

  1. Elaborazione sequenziale dei dati : questo può sembrare strano poiché vogliamo elaborare i dati utilizzando più thread. Tuttavia, la maggior parte degli scenari richiede più thread solo per completare il lavoro mentre altri thread attendono o eseguono operazioni di lunga durata. L'uso più comune sono i thread dell'interfaccia utente che aggiornano l'interfaccia utente in un singolo thread, mentre altri thread possono essere eseguiti in background, ma non possono accedere direttamente agli elementi dell'interfaccia utente. Per passare i risultati dai thread in background, vengono utilizzate le code dei processi che verranno elaborate dal singolo thread alla successiva ragionevole opportunità.
  2. Sincronizza l'accesso ai dati: questo è il modo più comune per gestire più thread che accedono agli stessi dati. La maggior parte dei linguaggi di programmazione ha classi e strumenti integrati per bloccare sezioni in cui i dati vengono letti e / o scritti contemporaneamente da più thread. Tuttavia, occorre prestare attenzione a non bloccare le operazioni. D'altra parte, questo approccio costa molto in termini di costi in applicazioni in tempo reale.
  3. Gestire le modifiche simultanee solo quando si verificano: questo approccio ottimistico può essere fatto se le collisioni si verificano raramente. I dati verranno letti e modificati se non vi era alcun accesso multiplo, ma esiste un meccanismo che rileva quando i dati sono stati aggiornati contemporaneamente. In tal caso, il singolo calcolo verrà eseguito nuovamente fino al successo.

Ecco alcuni esempi per ciascun approccio che può essere utilizzato in un sistema di entità:

  1. Pensiamo a a CollisionSystemche legge Positione RigidBodycomponenti e che dovrebbe aggiornare a Velocity. Invece di manipolare Velocitydirettamente, la CollisionSystemvolontà inserirà un CollisionEventnella coda di lavoro di un EventSystem. Questo evento verrà quindi elaborato in sequenza con altri aggiornamenti a Velocity.
  2. Un EntitySystemdefinisce un insieme di componenti che deve leggere e scrivere. Per ciascuno Entityacquisirà un blocco di lettura per ciascun componente che desidera leggere e un blocco di scrittura per ciascun componente che desidera aggiornare. In questo modo, ognuno EntitySystemsarà in grado di leggere contemporaneamente i componenti mentre le operazioni di aggiornamento sono sincronizzate.
  3. Prendendo l'esempio di MovementSystem, il Positioncomponente è immutabile e contiene un numero di revisione . La MovementSystemlegge Savelij l' Positione Velocitycomponenti e calcola il nuovo Position, incrementando la lettura di revisione numero e tentativi di aggiornamento del Positioncomponente. In caso di modifica simultanea, il framework lo indica sull'aggiornamento e Entityverrà riportato nell'elenco delle entità che devono essere aggiornate da MovementSystem.

A seconda dei sistemi, delle entità e degli intervalli di aggiornamento, ogni approccio potrebbe essere positivo o negativo. Un framework di sistema di entità potrebbe consentire all'utente di scegliere tra quelle opzioni per modificare le prestazioni.

Spero di poter aggiungere alcune idee alla discussione e per favore fatemi sapere se ci sono novità a riguardo.

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.