Quindi questo non è un sovraccarico di memoria?
Sì no forse?
Questa è una domanda imbarazzante perché immagina l'intervallo di indirizzamento della memoria sulla macchina e un software che deve tenere costantemente traccia di dove sono le cose in memoria in un modo che non può essere legato allo stack.
Ad esempio, immagina un lettore musicale in cui il file musicale viene caricato su un pulsante premuto dall'utente e scaricato dalla memoria volatile quando l'utente tenta di caricare un altro file musicale.
Come tenere traccia di dove sono archiviati i dati audio? Ci serve un indirizzo di memoria. Il programma non deve solo tenere traccia del blocco di dati audio in memoria, ma anche dove si trova in memoria. Pertanto, dobbiamo mantenere un indirizzo di memoria (ovvero un puntatore). E la dimensione della memoria richiesta per l'indirizzo di memoria corrisponderà all'intervallo di indirizzamento della macchina (es: puntatore a 64 bit per un intervallo di indirizzamento a 64 bit).
Quindi è un po '"sì", richiede memoria per tenere traccia di un indirizzo di memoria, ma non è che possiamo evitarlo per una memoria allocata dinamicamente di questo tipo.
Come viene compensato?
Parlando solo della dimensione di un puntatore stesso, è possibile evitare il costo in alcuni casi utilizzando lo stack, ad esempio In questo caso, i compilatori possono generare istruzioni che codificano effettivamente l'indirizzo di memoria relativo, evitando il costo di un puntatore. Tuttavia, questo ti rende vulnerabile agli overflow dello stack se lo fai per allocazioni di grandi dimensioni di dimensioni variabili e tende anche a essere poco pratico (se non addirittura impossibile) per una serie complessa di rami guidata dall'input dell'utente (come nell'esempio audio sopra).
Un altro modo è utilizzare strutture dati più contigue. Ad esempio, è possibile utilizzare una sequenza basata su array anziché un elenco doppiamente collegato che richiede due puntatori per nodo. Possiamo anche usare un ibrido di questi due come un elenco non srotolato che memorizza solo i puntatori tra ogni gruppo contiguo di N elementi.
I puntatori vengono utilizzati in applicazioni con memoria insufficiente nel tempo?
Sì, molto comunemente, poiché molte applicazioni critiche per le prestazioni sono scritte in C o C ++ che sono dominate dall'uso del puntatore (potrebbero essere dietro un puntatore intelligente o un contenitore come std::vector
o std::string
, ma la meccanica sottostante si riduce a un puntatore che viene utilizzato per tenere traccia dell'indirizzo su un blocco di memoria dinamica).
Ora torniamo a questa domanda:
Come viene compensato? (Seconda parte)
I puntatori sono in genere sporchi a meno che non li si memorizzi come un milione di essi (che è ancora miseramente * 8 megabyte su una macchina a 64 bit).
* Notare come Ben ha sottolineato che un "misero" 8 mega è ancora la dimensione della cache L3. Qui ho usato "misericordioso" di più nel senso dell'uso totale della DRAM e le dimensioni relative tipiche ai blocchi di memoria indicheranno un uso sano dei puntatori.
Dove i puntatori diventano costosi non sono i puntatori stessi ma:
Allocazione dinamica della memoria. L'allocazione dinamica della memoria tende ad essere costosa poiché deve passare attraverso una struttura di dati sottostante (es: buddy o slab allocator). Anche se questi sono spesso ottimizzati fino alla morte, sono di uso generale e progettati per gestire blocchi di dimensioni variabili che richiedono che facciano almeno un po 'di lavoro simile a una "ricerca" (anche se leggera e forse anche a tempo costante) per trova un set gratuito di pagine contigue in memoria.
Accesso alla memoria. Questo tende ad essere il più grande overhead di cui preoccuparsi. Ogni volta che accediamo alla memoria allocata in modo dinamico per la prima volta, c'è un errore di pagina obbligatorio, così come i mancati errori della cache che spostano la memoria nella gerarchia della memoria e in un registro.
Accesso alla memoria
L'accesso alla memoria è uno degli aspetti più critici delle prestazioni oltre agli algoritmi. Molti settori critici per le prestazioni come i motori di gioco AAA concentrano gran parte della loro energia verso ottimizzazioni orientate ai dati che si riducono a schemi e layout di accesso alla memoria più efficienti.
Una delle maggiori difficoltà prestazionali dei linguaggi di livello superiore che vogliono allocare separatamente ogni tipo definito dall'utente attraverso un garbage collector, ad esempio, è che possono frammentare un po 'la memoria. Ciò può essere particolarmente vero se non tutti gli oggetti vengono allocati contemporaneamente.
In questi casi, se si memorizza un elenco di un milione di istanze di un tipo di oggetto definito dall'utente, l'accesso a tali istanze in sequenza in un ciclo potrebbe essere piuttosto lento poiché è analogo a un elenco di un milione di puntatori che puntano a regioni di memoria disparate. In quei casi, l'architettura vuole recuperare la memoria dai livelli superiori, più lenti e più grandi della gerarchia in blocchi grandi e allineati con la speranza che i dati circostanti in tali blocchi possano essere raggiunti prima dello sfratto. Quando ogni oggetto in un tale elenco viene allocato separatamente, spesso finiamo per pagarlo con cache miss quando ogni iterazione successiva potrebbe essere caricata da un'area completamente diversa in memoria senza l'accesso ad oggetti adiacenti prima dello sfratto.
Molti compilatori per tali lingue stanno facendo davvero un ottimo lavoro in questi giorni nella selezione delle istruzioni e nell'allocazione dei registri, ma la mancanza di un controllo più diretto sulla gestione della memoria qui può essere killer (sebbene spesso meno soggetto a errori) e rendere ancora linguaggi come C e C ++ abbastanza popolari.
Ottimizzazione indiretta dell'accesso al puntatore
Negli scenari più critici per le prestazioni, le applicazioni utilizzano spesso pool di memoria che raggruppano la memoria da blocchi contigui per migliorare la località di riferimento. In tali casi, anche una struttura collegata come un albero o un elenco collegato può essere resa cache-friendly a condizione che il layout di memoria dei suoi nodi sia di natura contigua. Ciò sta effettivamente rendendo più economico il dereferenziamento del puntatore, anche se indirettamente migliorando la località di riferimento coinvolta nel dereferenziarlo.
Inseguendo puntatori intorno
Supponiamo di avere un elenco collegato singolarmente come:
Foo->Bar->Baz->null
Il problema è che se assegniamo tutti questi nodi separatamente a un allocatore per scopi generici (e forse non tutti in una volta), la memoria effettiva potrebbe essere dispersa in questo modo (diagramma semplificato):
Quando iniziamo a inseguire i puntatori e accediamo al Foo
nodo, iniziamo con un miss obbligatorio (e forse un errore di pagina) spostando un pezzo dalla sua regione di memoria da regioni più lente di memoria a regioni più veloci di memoria, in questo modo:
Questo ci fa memorizzare nella cache (possibilmente anche una pagina) un'area di memoria solo per accedere a una parte di essa ed eliminare il resto mentre inseguiamo i puntatori in questo elenco. Prendendo il controllo sull'allocatore di memoria, tuttavia, possiamo allocare un tale elenco in modo contiguo in questo modo:
... e quindi migliorare significativamente la velocità con cui possiamo dereferenziare questi puntatori ed elaborare le loro punte. Quindi, sebbene molto indiretto, possiamo velocizzare l'accesso del puntatore in questo modo. Ovviamente se li avessimo archiviati in modo contiguo in un array, non avremmo questo problema in primo luogo, ma l'allocatore di memoria qui che ci dà il controllo esplicito sul layout della memoria può salvare il giorno in cui è richiesta una struttura collegata.
* Nota: questo è un diagramma molto semplificato e una discussione sulla gerarchia della memoria e sulla località di riferimento, ma si spera sia appropriato per il livello della domanda.