Allocazione dinamica della memoria e gestione della memoria


17

In un gioco medio, ci sono centinaia o forse migliaia di oggetti nella scena. È completamente corretto allocare memoria per tutti gli oggetti, compresi i colpi di pistola (proiettili), dinamicamente tramite new () predefinito ?

Devo creare un pool di memoria per l'allocazione dinamica o non è necessario preoccuparsi di questo? Cosa succede se la piattaforma di destinazione sono dispositivi mobili?

C'è bisogno di un gestore della memoria in un gioco per cellulare, per favore? Grazie.

Lingua utilizzata: C ++; Attualmente sviluppato sotto Windows, ma pianificato per essere portato in seguito.


Quale lingua?
Kylotan,

@Kylotan: il linguaggio utilizzato: C ++ attualmente sviluppato sotto Windows ma pianificato per il porting in seguito.
Bunkai.Satori,

Risposte:


23

In un gioco medio, ci sono centinaia o forse migliaia di oggetti nella scena. È completamente corretto allocare memoria per tutti gli oggetti, compresi i colpi di pistola (proiettili), in modo dinamico tramite new () predefinito?

Dipende davvero da cosa intendi per "corretto". Se prendi il termine abbastanza letteralmente (e ignori qualsiasi concetto di correttezza del design implicito), allora sì, è perfettamente accettabile. Il programma verrà compilato ed eseguito correttamente.

Può funzionare in modo subottimale, ma può anche funzionare abbastanza bene da essere un gioco divertente e spedibile.

Devo creare un pool di memoria per l'allocazione dinamica o non è necessario preoccuparsi di questo? Cosa succede se la piattaforma di destinazione sono dispositivi mobili?

Profilo e vedi. In C ++, ad esempio, l'allocazione dinamica sull'heap è di solito un'operazione "lenta" (in quanto implica attraversare l'heap alla ricerca di un blocco di dimensioni adeguate). In C #, di solito è un'operazione estremamente veloce perché comporta poco più di un incremento. Implementazioni linguistiche diverse hanno caratteristiche prestazionali diverse per quanto riguarda l'allocazione della memoria, la frammentazione al rilascio, eccetera.

L'implementazione di un sistema di pooling di memoria può certamente comportare miglioramenti delle prestazioni e, poiché i sistemi mobili sono generalmente sottodimensionati rispetto ai sistemi desktop, è possibile vedere un guadagno maggiore su una particolare piattaforma mobile rispetto a un desktop. Ma ancora una volta, dovresti profilare e vedere: se, attualmente, il tuo gioco è lento ma l'allocazione / rilascio della memoria non viene visualizzato sul profiler come hot spot, implementando l'infrastruttura per ottimizzare l'allocazione della memoria e l'accesso probabilmente vinto " ti faccio guadagnare un sacco di soldi.

C'è bisogno di un gestore della memoria in un gioco per cellulare, per favore? Grazie.

Ancora una volta, profila e vedi. Il tuo gioco funziona bene ora? Quindi potrebbe non essere necessario preoccuparsi.

Tutto ciò a parte un ammonimento, a parte, usare l'allocazione dinamica per tutto non è strettamente necessario e quindi può essere vantaggioso evitarlo - sia a causa dei potenziali guadagni delle prestazioni, sia a causa dell'allocazione della memoria che è necessario tenere traccia e infine rilasciare significa che devi tenere traccia e infine rilasciarlo, eventualmente complicando il tuo codice.

In particolare, nel tuo esempio originale hai citato "proiettili", che tendono ad essere qualcosa che viene creato e distrutto frequentemente - perché molti giochi coinvolgono molti proiettili e i proiettili si muovono velocemente e quindi raggiungono rapidamente la fine della loro vita (e spesso violentemente!). Pertanto l'implementazione di un allocatore di pool per loro e oggetti come questi (come le particelle in un sistema di particelle) di solito possono comportare incrementi di efficienza e probabilmente sarebbero il primo posto per iniziare a esaminare l'allocazione di pool.

Non sono chiaro se si considera che un'implementazione del pool di memoria sia distinta da un "gestore della memoria": un pool di memoria è un concetto relativamente ben definito, quindi posso affermare con certezza che possono essere un vantaggio se le implementate . Un "gestore della memoria" è un po 'più vago in termini di responsabilità, quindi dovrei dire che se uno è richiesto o meno dipende da cosa pensi che farebbe "gestore della memoria".

Ad esempio, se consideri un gestore di memoria come una cosa che intercetta le chiamate a new / delete / free / malloc / qualunque e fornisce la diagnostica su quanta memoria alloci, cosa perdi, eccetera, allora può essere utile strumento per il gioco mentre è in sviluppo per aiutarti a eseguire il debug delle perdite e ottimizzare le dimensioni ottimali del pool di memoria e così via.


Concordato. Codice in un modo che ti consente di cambiare le cose in seguito. In caso di dubbi, benchmark o profilo.
axel22,

@Josh: +1 per una risposta eccellente. Quello che probabilmente avrei bisogno di avere è una combinazione di allocazione dinamica, allocazione statica e pool di memoria. Tuttavia, le prestazioni del gioco mi guideranno nel giusto mix di quei tre. Questo è un chiaro candidato per la risposta accettata per la mia domanda. Tuttavia, vorrei tenere aperta la domanda per un po ', per vedere a cosa contribuiranno gli altri.
Bunkai.Satori,

+1. Ottima elaborazione. La risposta a quasi tutte le domande sulla performance è sempre "profilare e vedere". L'hardware è troppo complesso in questi giorni per ragionare sulle prestazioni dai primi principi. Hai bisogno di dati.
munifico

@ Magnifico: grazie per il tuo commento. Quindi l'obiettivo è rendere il gioco funzionante e stalbe. Non è necessario preoccuparsi troppo delle prestazioni nel mezzo dello sviluppo. Tutto può e verrà risolto dopo il completamento del gioco.
Bunkai.Satori,

Penso che questa sia una rappresentazione ingiusta del tempo di allocazione di C # - ad esempio, ogni allocazione di C # include anche un blocco di sincronizzazione, l'allocazione di Object, ecc. Inoltre, l'heap in C ++ richiede modifiche solo durante l'allocazione e la liberazione, mentre C # richiede le raccolte .
DeadMG

7

Non ho molto da aggiungere all'ottima risposta di Josh, ma commenterò questo:

Devo creare un pool di memoria per l'allocazione dinamica o non è necessario preoccuparsi di questo?

C'è una via di mezzo tra i pool di memoria e l'invio di newogni allocazione. Ad esempio, è possibile allocare un determinato numero di oggetti in un array, quindi impostare un flag su di essi per "distruggerli" in un secondo momento. Quando è necessario allocare di più, è possibile sovrascrivere quelli con il set di flag distrutto. Questo tipo di cose è solo leggermente più complesso da usare rispetto a new / delete (dato che avresti 2 nuove funzioni a tale scopo) ma è semplice da scrivere e può darti grandi guadagni.


+1 per una bella aggiunta. Sì, hai ragione, questo è un buon modo per gestire elementi di gioco più semplici come: proiettili, particelle, effetti. Soprattutto per quelli, non sarebbe necessario allocare la memoria in modo dinamico.
Bunkai.Satori,

3

È completamente corretto allocare memoria per tutti gli oggetti, compresi i colpi di pistola (proiettili), in modo dinamico tramite new () predefinito?

No certo che no. Nessuna allocazione di memoria è corretta per tutti gli oggetti. L'operatore new () è per l' allocazione dinamica , ovvero è appropriato solo se è necessario che l'allocazione sia dinamica, perché la durata dell'oggetto è dinamica o perché il tipo di oggetto è dinamico. Se il tipo e la durata dell'oggetto sono noti staticamente, è necessario allocare staticamente.

Naturalmente, più informazioni hai sui tuoi schemi di allocazione, più velocemente queste allocazioni possono essere fatte tramite allocatori specializzati, come pool di oggetti. Ma queste sono ottimizzazioni e dovresti farle solo se sono note per essere necessarie.


+1 per una buona risposta. Quindi, per generalizzare, l'approccio corretto sarebbe: all'inizio dello sviluppo, pianificare, quali oggetti possono essere allocati staticamente. Durante lo sviluppo, allocare dinamicamente solo quegli oggetti che devono assolutamente essere allocati dinamicamente. Alla fine, per profilare e regolare possibili problemi di prestazioni di allocazione della memoria.
Bunkai.Satori

0

Un po 'di eco al suggerimento di Kylotan ma consiglierei di risolverlo a livello di struttura dati quando possibile, non a livello di allocatore inferiore se puoi aiutarlo.

Ecco un semplice esempio di come puoi evitare di allocare e liberare Foosripetutamente usando un array con buchi con elementi collegati insieme (risolvendolo a livello di "contenitore" anziché a livello di "allocatore"):

struct FooNode
{
    explicit FooNode(const Foo& ielement): element(ielement), next(-1) {}

    // Stores a 'Foo'.
    Foo element;

    // Points to the next foo available; either the
    // next used foo or the next deleted foo. Can
    // use SoA and hoist this out if Foo doesn't 
    // have 32-bit alignment.
    int next;
};

struct Foos
{
    // Stores all the Foo nodes.
    vector<FooNode> nodes;

    // Points to the first used node.
    int first_node;

    // Points to the first free node.
    int free_node;

    Foos(): first_node(-1), free_node(-1)
    {
    }

    const FooNode& operator[](int n) const
    {
         return data[n];
    }

    void insert(const Foo& element)
    {
         int index = free_node;
         if (index != -1)
         {
              // If there's a free node available,
              // pop it from the free list, overwrite it,
              // and push it to the used list.
              free_node = data[index].next;
              data[index].next = first_node;
              data[index].element = element;
              first_node = index;
         }
         else
         {
              // If there's no free node available, add a 
              // new node and push it to the used list.
              FooNode new_node(element);
              new_node.next = first_node;
              first_node = data.size() - 1;
              data.push_back(new_node);
         }
    }

    void erase(int n)
    {
         // If the node being removed is the first used
         // node, pop it from the used list.
         if (first_node == n)
              first_node = data[n].next;

         // Push the node to the free list.
         data[n].next = free_node;
         free_node = n;
    }
};

Qualcosa a tal fine: un elenco di indici collegati singolarmente con un elenco gratuito. I collegamenti di indice consentono di saltare gli elementi rimossi, rimuovere gli elementi in tempo costante e anche recuperare / riutilizzare / sovrascrivere gli elementi liberi con l'inserimento in tempo costante. Per scorrere la struttura, fai qualcosa del genere:

for (int index = foos.first_node; index != -1; index = foos[index].next)
    // do something with foos[index]

inserisci qui la descrizione dell'immagine

E puoi generalizzare il suddetto tipo di struttura di dati "array collegato di fori" utilizzando modelli, posizionando invocazioni di nuovi e manuali manuali per evitare il requisito di assegnazione di copie, farlo invocare distruttori quando gli elementi vengono rimossi, fornire un iteratore in avanti, ecc. I ho scelto di mantenere l'esempio molto simile a C per illustrare più chiaramente il concetto e anche perché sono molto pigro.

Detto questo, questa struttura tende a degradare nella località spaziale dopo aver rimosso e inserito molte cose da / verso il centro. A quel punto i nextcollegamenti potrebbero farti camminare avanti e indietro lungo il vettore, ricaricando i dati precedentemente sfrattati da una linea di cache all'interno dello stesso attraversamento sequenziale (questo è inevitabile con qualsiasi struttura di dati o allocatore che consente la rimozione a tempo costante senza mescolare gli elementi durante il recupero spazi dal centro con inserimento a tempo costante e senza usare qualcosa come un bitset parallelo o una removedbandiera). Per ripristinare la compatibilità con la cache, è possibile implementare un metodo ctor e swap di copia come questo:

Foos(const Foos& other)
{
    for (int index = other.first_node; index != -1; index = other[index].next)
        insert(foos[index].element);
}

void Foos::swap(Foos& other)
{
     nodes.swap(other.nodes):
     std::swap(first_node, other.first_node);
     std::swap(free_node, other.free_node);
}

// ... then just copy and swap:
Foos(foos).swap(foos);

Ora la nuova versione è di nuovo compatibile con la cache da attraversare. Un altro metodo è memorizzare un elenco separato di indici nella struttura e ordinarli periodicamente. Un altro è usare un bitset per indicare quali indici sono usati. Ciò ti farà sempre attraversare il bitset in ordine sequenziale (per farlo in modo efficiente, controlla 64 bit alla volta, ad es. Usando FFS / FFZ). Il bitset è il più efficiente e non invadente, richiede solo un bit parallelo per elemento per indicare quali vengono utilizzati e quali vengono rimossi invece di richiedere nextindici a 32 bit , ma il più dispendioso in termini di tempo per scrivere bene (non lo farà sii veloce per l'attraversamento se stai controllando un bit alla volta - hai bisogno di FFS / FFZ per trovare un bit impostato o non impostato immediatamente tra più di 32 bit alla volta per determinare rapidamente intervalli di indici occupati).

Questa soluzione collegata è generalmente la più semplice da implementare e non intrusiva (non richiede la modifica Fooper memorizzare alcuni removedflag), il che è utile se si desidera generalizzare questo contenitore per lavorare con qualsiasi tipo di dati se non ti dispiace che a 32 bit spese generali per elemento.

Devo creare un pool di memoria per l'allocazione dinamica o non è necessario preoccuparsi di questo? Cosa succede se la piattaforma di destinazione sono dispositivi mobili?

bisogno è una parola forte e sto lavorando di parte in aree molto critiche per le prestazioni come raytracing, elaborazione di immagini, simulazioni di particelle ed elaborazione di mesh, ma è relativamente molto costoso allocare e liberare oggetti teen usati per l'elaborazione molto leggera come i proiettili e particelle individualmente contro un allocatore di memoria di dimensioni variabili per scopi generici. Dato che dovresti essere in grado di generalizzare la struttura di dati di cui sopra in un giorno o due per archiviare tutto ciò che desideri, penso che sarebbe uno scambio utile eliminare completamente tali costi di allocazione / deallocazione dell'heap da pagare per ogni singola cosa da teenager. Oltre a ridurre i costi di allocazione / deallocazione, si ottiene una migliore località di riferimento che attraversa i risultati (meno errori nella cache e errori di pagina, ad esempio).

Per quanto riguarda ciò che Josh ha menzionato di GC, non ho studiato l'implementazione GC di C # con la stessa precisione di quella di Java, ma gli allocatori GC hanno spesso un allocazione inizialeè molto veloce perché utilizza un allocatore sequenziale che non può liberare memoria dal centro (quasi come uno stack, non è possibile eliminare le cose dal centro). Quindi paga per i costi costosi consentire effettivamente la rimozione di singoli oggetti in un thread separato copiando la memoria e eliminando la memoria precedentemente allocata nel suo insieme (come distruggere l'intero stack contemporaneamente mentre copiando i dati in qualcosa di più simile a una struttura collegata), ma poiché è fatto in un thread separato, non necessariamente blocca così tanto i thread dell'applicazione. Tuttavia, ciò comporta un costo nascosto molto significativo di un ulteriore livello di indiretta e della perdita generale di LOR dopo un ciclo GC iniziale. Tuttavia, è un'altra strategia per accelerare l'allocazione: renderla più economica nel thread di chiamata e quindi svolgere il lavoro costoso in un'altra. Per questo sono necessari due livelli di riferimento indiretto per fare riferimento ai tuoi oggetti anziché uno poiché finiranno per essere rimescolati nella memoria tra il momento in cui si allocano inizialmente e dopo un primo ciclo.

Un'altra strategia in una prospettiva simile che è un po 'più facile da applicare in C ++ è semplicemente non preoccuparti di liberare i tuoi oggetti nei thread principali. Continuando semplicemente ad aggiungere e aggiungere e aggiungere alla fine di una struttura di dati che non consente di rimuovere le cose dal centro. Tuttavia, contrassegnare quelle cose che devono essere rimosse. Quindi un thread separato potrebbe occuparsi del costoso lavoro di creazione di una nuova struttura di dati senza gli elementi rimossi e quindi scambiare atomicamente quello nuovo con quello vecchio, ad es. Gran parte del costo di allocazione e liberazione degli elementi può essere trasferito a un thread separato se è possibile supporre che la richiesta di rimozione di un elemento non debba essere soddisfatta immediatamente. Ciò non solo rende la liberazione più economica per quanto riguarda i thread, ma rende l'allocazione più economica, poiché puoi utilizzare una struttura di dati molto più semplice e più stupida che non deve mai gestire i casi di rimozione dal centro. È come un contenitore che ha solo bisogno di unpush_backfunzione per inserimento, una clearfunzione per rimuovere tutti gli elementi e swapscambiare i contenuti con un nuovo contenitore compatto esclusi gli elementi rimossi; per quanto riguarda le mutazioni.

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.