Le matrici non contigue sono performanti?


12

In C #, quando un utente crea un List<byte>e aggiunge byte ad esso, è possibile che rimanga senza spazio e debba allocare più spazio. Alloca il doppio (o qualche altro moltiplicatore) delle dimensioni dell'array precedente, copia i byte e scarta il riferimento all'array precedente. So che l'elenco cresce in modo esponenziale perché ogni allocazione è costosa e questo lo limita alle O(log n)allocazioni, dove l'aggiunta di 10elementi extra ogni volta comporterebbe O(n)allocazioni.

Tuttavia, per array di grandi dimensioni può esserci molto spazio sprecato, forse quasi metà dell'array. Per ridurre la memoria ho scritto una classe simile NonContiguousArrayListche utilizza List<byte>come archivio di backup se l'elenco contenesse meno di 4 MB, quindi allocarebbe ulteriori array di byte da 4 MB con l' NonContiguousArrayListaumentare delle dimensioni.

A differenza di List<byte>questi array, non contigui, quindi non è possibile copiare i dati, ma solo un'allocazione aggiuntiva di 4 milioni. Quando si cerca un elemento, l'indice viene diviso per 4 M per ottenere l'indice dell'array che contiene l'elemento, quindi modulo 4 M per ottenere l'indice all'interno dell'array.

Puoi indicare problemi con questo approccio? Ecco la mia lista:

  • 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.
  • L'accesso a un elemento non è così semplice, c'è un ulteriore livello di riferimento indiretto. Questo verrebbe ottimizzato? Provocherebbe problemi di cache?
  • 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.

8
Hai esaurito la teoria (preso in considerazione la cache, discusso della complessità asintotica), tutto ciò che resta è collegare i parametri (qui, 4 milioni di voci per elenco secondario) e forse micro-ottimizzare. Ora è il momento di fare il benchmark, perché senza riparare l'hardware e l'implementazione, ci sono troppo pochi dati per discutere ulteriormente delle prestazioni.

3
Se stai lavorando con più di 4 milioni di elementi in una singola raccolta, mi aspetto che la micro-ottimizzazione del contenitore sia il minimo dei tuoi problemi di prestazioni.
Telastyn,

2
Quello che descrivi è simile a un elenco collegato non srotolato (con nodi molto grandi). La tua affermazione che non hanno una localizzazione cache è leggermente sbagliata. Solo così tanto di un array si inserisce in una singola riga della cache; diciamo 64 byte. Quindi ogni 64 byte ti mancherà la cache. Consideriamo ora un elenco collegato non srotolato i cui nodi sono precisamente grandi più di 64 byte (inclusa l'intestazione dell'oggetto per la garbage collection). Avresti comunque perso solo una cache ogni 64 byte e non importa nemmeno che i nodi non siano adiacenti in memoria.
Doval,

@Doval Non è in realtà un elenco collegato non srotolato, poiché i blocchi 4M sono memorizzati in un array stesso, quindi l'accesso a qualsiasi elemento è O (1) non O (n / B) dove B è la dimensione del blocco.

2
@ user2313838 Se ci fossero 1000 MB di memoria e un array da 350 MB, la memoria necessaria per far crescere l'array sarebbe 1050 MB, maggiore di quello disponibile, questo è il problema principale, il limite effettivo è 1/3 dello spazio totale. TrimExcesssarebbe utile solo quando l'elenco è già stato creato e anche in questo caso richiede ancora spazio sufficiente per la copia.
Noisecapella,

Risposte:


5

Alle scale che hai menzionato, le preoccupazioni sono totalmente diverse da quelle che hai menzionato.

Località cache

  • Esistono due concetti correlati:
    1. Località, il riutilizzo dei dati sulla stessa linea di cache (località spaziale) recentemente visitata (località temporale)
    2. Prefetching automatico della cache (streaming).
  • Alle scale menzionate (da centinaia di MB a gigabyte, in blocchi da 4 MB), i due fattori hanno più a che fare con il modello di accesso dell'elemento dati rispetto al layout di memoria.
  • La mia (clueless) previsione è che statisticamente potrebbe non esserci molta differenza di prestazioni rispetto a una gigantesca allocazione di memoria contigua. Nessun guadagno, nessuna perdita.

Pattern di accesso agli elementi dati

  • Questo articolo illustra visivamente come i modelli di accesso alla memoria influenzeranno le prestazioni.
  • In breve, tieni presente che se il tuo algoritmo è già strozzato dalla larghezza di banda della memoria, l'unico modo per migliorare le prestazioni è fare un lavoro più utile con i dati che sono già caricati nella cache.
  • In altre parole, anche se YourList[k]e YourList[k+1]ha un'alta probabilità di essere consecutivi (una possibilità su quattro milioni di non esserlo), questo fatto non aiuterà le prestazioni se accedi al tuo elenco in modo completamente casuale o con grandi passi imprevedibili, ad es.while { index += random.Next(1024); DoStuff(YourList[index]); }

Interazione con il sistema GC

Spese generali di calcolo dell'offset dell'indirizzo

  • Il tipico codice C # sta già eseguendo molti calcoli di offset dell'indirizzo, quindi l'overhead aggiuntivo dal tuo schema non sarebbe peggiore del tipico codice C # che funziona su un singolo array.
    • Ricorda che il codice C # esegue anche il controllo dell'intervallo di array; e questo fatto non impedisce a C # di raggiungere prestazioni di elaborazione dell'array comparabili con il codice C ++.
    • Il motivo è che le prestazioni sono per lo più strozzate dalla larghezza di banda della memoria.
    • Il trucco per massimizzare l'utilità dalla larghezza di banda della memoria è utilizzare le istruzioni SIMD per le operazioni di lettura / scrittura della memoria. Né C # tipico né C ++ tipico fa questo; devi ricorrere a librerie o componenti aggiuntivi in ​​lingua.

Per illustrare perché:

  • Calcola indirizzo
  • (Nel caso di OP, caricare l'indirizzo di base del blocco (che è già nella cache) e quindi eseguire più calcoli dell'indirizzo)
  • Leggi / scrivi all'indirizzo dell'elemento

L'ultimo passo richiede ancora la parte del tempo del leone.

Suggerimento personale

  • Puoi fornire una CopyRangefunzione, che si comporterebbe come una Array.Copyfunzione ma opererebbe tra due istanze della tua NonContiguousByteArrayo tra un'istanza e l'altra normale byte[]. queste funzioni possono utilizzare il codice SIMD (C ++ o C #) per massimizzare l'utilizzo della larghezza di banda della memoria, e quindi il codice C # può operare sull'intervallo copiato senza l'overhead di più dereferenze o calcolo dell'indirizzo.

Problemi di usabilità e interoperabilità

  • Apparentemente non è possibile utilizzarlo NonContiguousByteArraycon librerie C #, C ++ o in lingue straniere che prevedono array di byte contigui o array di byte che possono essere bloccati.
  • Tuttavia, se si scrive la propria libreria di accelerazione C ++ (con P / Invoke o C ++ / CLI), è possibile passare un elenco di indirizzi di base di diversi blocchi da 4 MB nel codice sottostante.
    • Ad esempio, se è necessario consentire l'accesso agli elementi che iniziano (3 * 1024 * 1024)e terminano in (5 * 1024 * 1024 - 1), ciò significa che l'accesso si estenderà attraverso chunk[0]e chunk[1]. È quindi possibile costruire un array (dimensione 2) di array di byte (dimensione 4M), aggiungere questi indirizzi di blocchi e passarli al codice sottostante.
  • Un'altra preoccupazione di usabilità è che non sarai in grado di implementare l' IList<byte>interfaccia in modo efficiente: Inserte Removeci vorrà solo troppo tempo per l'elaborazione perché richiederà O(N)tempo.
    • In effetti, sembra che non sia possibile implementare nient'altro che IEnumerable<byte>, cioè può essere scansionato in sequenza e basta.

2
Sembra che tu abbia perso il vantaggio principale della struttura dei dati, ovvero che ti consente di creare elenchi molto grandi, senza esaurire la memoria. Quando si espande l'elenco <T>, è necessario un nuovo array grande il doppio di quello precedente ed entrambi devono essere presenti in memoria contemporaneamente.
Frank Hileman,

6

Vale la pena di notare che C ++ ha già una struttura equivalente da parte di Standard, std::deque. Attualmente, è consigliata come scelta predefinita per la necessità di una sequenza di cose ad accesso casuale.

La realtà è che la memoria contigua è quasi completamente inutile quando i dati superano una certa dimensione: una riga della cache è di soli 64 byte e una dimensione della pagina è solo di 4-8 KB (valori tipici attualmente). Una volta che inizi a parlare di alcuni MB, si spegne davvero dalla finestra. Lo stesso vale per i costi di allocazione. Il prezzo dell'elaborazione di tutti quei dati, anche solo leggendoli, riduce comunque il prezzo delle allocazioni.

L'unico altro motivo per preoccuparsene è l'interfaccia con le API C. Tuttavia, non è possibile ottenere un puntatore al buffer di un elenco, quindi non ci sono problemi qui.


È interessante, non sapevo che dequeavesse un'implementazione simile
noisecapella,

Chi sta attualmente raccomandando std :: deque? Potete fornire una fonte? Ho sempre pensato che std :: vector fosse la scelta predefinita raccomandata.
Teimpz,

std::dequeè in realtà altamente scoraggiato, in parte perché l'implementazione della libreria standard MS è così male.
Sebastian Redl il

3

Quando i blocchi di memoria sono allocati in diversi punti nel tempo, come nei sotto-array all'interno della struttura dei dati, possono essere posizionati distanti tra loro in memoria. Se questo è un problema o meno dipende dalla CPU ed è molto difficile prevederlo. Devi provarlo.

Questa è un'idea eccellente, ed è una che ho usato in passato. Ovviamente dovresti usare solo due potenze per le dimensioni del tuo sub-array e lo spostamento dei bit per la divisione (può accadere come parte dell'ottimizzazione). Ho trovato questo tipo di struttura leggermente più lenta, in quanto i compilatori possono ottimizzare più facilmente una singola indiretta matrice. Devi testare, poiché questi tipi di ottimizzazioni cambiano continuamente.

Il vantaggio principale è che puoi correre più vicino al limite superiore di memoria nel tuo sistema, purché usi costantemente questi tipi di strutture. Finché si ingrandiscono le strutture dei dati e non si producono rifiuti, si evitano raccolte di rifiuti extra che si verificherebbero per un normale elenco. Per un elenco gigante, potrebbe fare una differenza enorme: la differenza tra continuare a correre e esaurire la memoria.

Le allocazioni extra sono un problema solo se i blocchi di sub-array sono piccoli, poiché esiste un sovraccarico di memoria in ogni allocazione di array.

Ho creato strutture simili per dizionari (tabelle hash). Il dizionario fornito dal framework .net presenta lo stesso problema dell'elenco. I dizionari sono più difficili in quanto è necessario evitare anche il reinserimento.


Un collettore compatto può compattare blocchi uno accanto all'altro.
DeadMG

@DeadMG Mi riferivo alla situazione in cui ciò non può accadere: ci sono altri pezzi nel mezzo, che non sono spazzatura. Con List <T>, hai la garanzia di memoria contigua per il tuo array. Con un elenco a blocchi, la memoria è contigua solo all'interno di un blocco, a meno che tu non abbia la fortunata situazione di compattazione che menzioni. Ma una compattazione può anche richiedere lo spostamento di molti dati in giro e grandi array vanno nell'heap di oggetti di grandi dimensioni. È complicato.
Frank Hileman,

2

Con una dimensione di blocco di 4 M, non è garantito che un singolo blocco sia contiguo nella memoria fisica; è più grande di una tipica dimensione di pagina VM. Località non significativa a quella scala.

Dovrai preoccuparti della frammentazione dell'heap: se le allocazioni avvengono in modo tale che i tuoi blocchi siano in gran parte non contigui nell'heap, quando vengono recuperati dal GC, ti ritroverai con un heap che potrebbe essere troppo frammentato per adattarsi a un assegnazione successiva. Di solito è una situazione peggiore perché si verificheranno errori in luoghi non correlati e potrebbero forzare un riavvio dell'applicazione.


I GC di compattazione sono privi di frammentazione.
DeadMG

Questo è vero, ma la compattazione LOH è disponibile solo da .NET 4.5 se ricordo bene.
user2313838

La compattazione dell'heap può inoltre comportare un sovraccarico maggiore rispetto al comportamento di copia su riallocazione dello standard List.
user2313838

Un oggetto abbastanza grande e di dimensioni adeguate è comunque effettivamente privo di frammentazione.
DeadMG

2
@DeadMG: la vera preoccupazione per la compattazione GC (con questo schema da 4 MB) è che potrebbe passare del tempo inutile a spalancare questi bovini da 4 MB. Di conseguenza, potrebbero verificarsi grandi pause GC. Per questo motivo, quando si utilizza questo schema da 4 MB, è importante monitorare le statistiche GC fondamentali per vedere cosa sta facendo e intraprendere azioni correttive.
dal

1

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).

inserisci qui la descrizione dell'immagine

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

  1. 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.
  2. 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.
  3. 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.
  4. 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::vectora quando non si compatta vectorper eliminare la capacità in eccesso che si riserva. Inoltre, generalmente non lo uso per memorizzare elementi così adolescenti.

Professionisti

  1. L'accesso sequenziale che utilizza una for_eachfunzione 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).
  2. 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.
  3. 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.
  4. 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.
  5. 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:

inserisci qui la descrizione dell'immagine

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.

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.