Ruolo alcune delle parti più centrali del mio codebase (un motore ECS) attorno al tipo di struttura di dati che hai descritto, sebbene utilizzi blocchi contigui più piccoli (più come 4 kilobyte anziché 4 megabyte).
Utilizza una doppia lista libera per ottenere inserimenti e rimozioni a tempo costante con una lista libera per blocchi liberi che sono pronti per essere inseriti (blocchi che non sono pieni) e una lista sub-libera all'interno del blocco per gli indici in quel blocco pronto per essere recuperato al momento dell'inserimento.
Tratterò i pro e i contro di questa struttura. Cominciamo con alcuni contro perché ce ne sono alcuni:
Contro
- Ci vuole circa 4 volte di più per inserire un paio di centinaia di milioni di elementi in questa struttura rispetto a
std::vector
(una struttura puramente contigua). E sono abbastanza decente in termini di microottimizzazioni, ma concettualmente c'è ancora molto lavoro da fare poiché il caso comune deve prima ispezionare il blocco libero in cima all'elenco libero dei blocchi, quindi accedere al blocco e far apparire un indice libero dal blocco elenco libero, scrivere l'elemento nella posizione libera, quindi controllare se il blocco è pieno e pop il blocco dall'elenco libero del blocco in tal caso. È ancora un'operazione a tempo costante, ma con una costante molto più grande di quella di respingere std::vector
.
- Richiede circa il doppio dell'accesso agli elementi utilizzando un modello ad accesso casuale, dato l'aritmetica aggiuntiva per l'indicizzazione e il livello aggiuntivo di indiretta.
- L'accesso sequenziale non si associa in modo efficiente a un progetto iteratore poiché l'iteratore deve eseguire ramificazioni aggiuntive ogni volta che viene incrementato.
- Ha un po 'di memoria overhead, in genere circa 1 bit per elemento. 1 bit per elemento potrebbe non sembrare molto, ma se lo si utilizza per memorizzare un milione di numeri interi a 16 bit, il 6,25% in più di memoria rispetto a un array perfettamente compatto. Tuttavia, in pratica ciò tende a utilizzare meno memoria rispetto
std::vector
a quando non si compatta vector
per eliminare la capacità in eccesso che si riserva. Inoltre, generalmente non lo uso per memorizzare elementi così adolescenti.
Professionisti
- L'accesso sequenziale che utilizza una
for_each
funzione che accetta un callback che elabora intervalli di elementi all'interno di un blocco è quasi uguale alla velocità dell'accesso sequenziale con std::vector
(solo come una differenza del 10%), quindi per me non è molto meno efficiente nei casi d'uso più critici per le prestazioni ( la maggior parte del tempo trascorso in un motore ECS è in accesso sequenziale).
- Permette rimozioni a tempo costante dal centro con la struttura che distribuisce i blocchi quando diventano completamente vuoti. Di conseguenza è generalmente abbastanza decente nel garantire che la struttura dei dati non usi mai molta più memoria del necessario.
- Non invalida gli indici agli elementi che non vengono rimossi direttamente dal contenitore poiché lascia solo dei buchi usando un approccio a lista libera per recuperare quei buchi al successivo inserimento.
- Non devi preoccuparti così tanto di esaurire la memoria anche se questa struttura contiene un numero epico di elementi, poiché richiede solo piccoli blocchi contigui che non rappresentano una sfida per il sistema operativo per trovare un numero enorme di inutilizzati contigui pagine.
- Si presta bene alla concorrenza e alla sicurezza dei thread senza bloccare l'intera struttura, poiché le operazioni sono generalmente localizzate in singoli blocchi.
Ora uno dei più grandi pro per me è stato che diventa banale creare una versione immutabile di questa struttura di dati, come questa:
Da allora, questo ha aperto tutti i tipi di porte alla scrittura di più funzioni prive di effetti collaterali che hanno reso molto più facile ottenere eccezioni, sicurezza dei thread, ecc. L'immutabilità è stata una specie di cosa che ho scoperto che avrei potuto facilmente raggiungere con questa struttura di dati con il senno di poi e per caso, ma senza dubbio uno dei vantaggi più belli che ha finito per aver reso il mantenimento della base di codice molto più semplice.
Le matrici non contigue non hanno una localizzazione cache che determina prestazioni scadenti. Tuttavia, a una dimensione di blocco di 4 m, sembra che ci sarebbe abbastanza località per una buona memorizzazione nella cache.
La località di riferimento non è qualcosa di cui preoccuparsi con blocchi di quelle dimensioni, per non parlare di blocchi da 4 kilobyte. Una linea di cache è in genere di soli 64 byte. Se vuoi ridurre i mancati cache, concentrati solo sull'allineamento di quei blocchi e favorisci schemi di accesso più sequenziali quando possibile.
Un modo molto rapido per trasformare un modello di memoria ad accesso casuale in uno sequenziale è usare un bitset. Supponiamo che tu abbia un carico di indici e che siano in ordine casuale. Puoi semplicemente scavarli e contrassegnare i bit nel bitset. Quindi puoi scorrere il tuo set di bit e verificare quali byte sono diversi da zero, controllando, diciamo, 64 bit alla volta. Quando si incontra un set di 64 bit di cui è impostato almeno un bit, è possibile utilizzare le istruzioni FFS per determinare rapidamente quali bit sono impostati. I bit ti dicono a quali indici dovresti accedere, tranne ora che ottieni gli indici ordinati in ordine sequenziale.
Questo ha un certo sovraccarico, ma può essere uno scambio utile in alcuni casi, soprattutto se si ripetono più volte questi indici.
L'accesso a un elemento non è così semplice, c'è un ulteriore livello di riferimento indiretto. Questo verrebbe ottimizzato? Provocherebbe problemi di cache?
No, non può essere ottimizzato via. L'accesso casuale, almeno, costerà sempre di più con questa struttura. Spesso non aumenterà così tanto la mancanza di cache, poiché tenderai ad ottenere un'elevata località temporale con l'array di puntatori a blocchi, specialmente se i percorsi di esecuzione del caso comune utilizzano schemi di accesso sequenziale.
Dato che c'è una crescita lineare dopo che il limite di 4M è stato raggiunto, potresti avere molte più allocazioni di quelle che avresti normalmente (diciamo, un massimo di 250 allocazioni per 1 GB di memoria). Nessuna memoria aggiuntiva viene copiata dopo 4M, tuttavia non sono sicuro che le allocazioni aggiuntive siano più costose della copia di grossi blocchi di memoria.
In pratica, la copia è spesso più veloce perché è un caso raro, si verifica solo qualcosa come i log(N)/log(2)
tempi totali, semplificando allo stesso tempo il caso comune economico sporco in cui è possibile scrivere un elemento sull'array molte volte prima che si riempia e debba essere riallocato di nuovo. Quindi in genere non si ottengono inserimenti più veloci con questo tipo di struttura perché il caso comune è più costoso anche se non deve affrontare quel raro caso costoso di riallocare matrici enormi.
Il fascino principale di questa struttura per me, nonostante tutti i contro è il ridotto uso della memoria, non dovendo preoccuparsi di OOM, essere in grado di memorizzare indici e puntatori che non vengono invalidati, concorrenza e immutabilità. È bello avere una struttura di dati in cui è possibile inserire e rimuovere elementi in tempo costante mentre si pulisce da soli e non invalida i puntatori e gli indici nella struttura.