Qualche ottimizzazione per l'accesso casuale su un array molto grande quando il valore nel 95% dei casi è 0 o 1?


133

Esiste una possibile ottimizzazione per l'accesso casuale su un array molto grande (attualmente uso uint8_te sto chiedendo cosa c'è di meglio)

uint8_t MyArray[10000000];

quando il valore in qualsiasi posizione dell'array è

  • 0 o 1 per il 95% di tutti i casi,
  • 2 nel 4% dei casi,
  • tra il 3 e il 255 nell'altro 1% dei casi?

Quindi, c'è qualcosa di meglio di un uint8_tarray da utilizzare per questo? Dovrebbe essere il più veloce possibile eseguire un ciclo su tutto l'array in un ordine casuale, e questo è molto pesante sulla larghezza di banda RAM, quindi quando si hanno più di un thread che lo fanno contemporaneamente per array diversi, attualmente l'intera larghezza di banda RAM è rapidamente saturo.

Lo sto chiedendo poiché sembra molto inefficiente avere un array così grande (10 MB) quando in realtà è noto che quasi tutti i valori, tranne il 5%, saranno 0 o 1. Quindi quando il 95% di tutti i valori nell'array sarebbe effettivamente necessario solo 1 bit anziché 8 bit, questo ridurrebbe l'utilizzo della memoria di quasi un ordine di grandezza. Sembra che ci debba essere una soluzione più efficiente in termini di memoria che ridurrebbe notevolmente la larghezza di banda RAM richiesta per questo, e di conseguenza anche essere significativamente più veloce per l'accesso casuale.


36
Due bit (0/1 / vedi tabella hash) e una tabella hash per valori maggiori di 1?
user253751

6
@ user202729 Da cosa dipende? Penso che questa sia una domanda interessante per chiunque debba fare qualcosa di simile come me, quindi vorrei vedere una soluzione più universale per questo, non una risposta super specifica per il mio codice. Se dipende da qualcosa, sarebbe bene avere una risposta che spieghi da cosa dipende in modo che tutti coloro che leggono possano capire se esiste una soluzione migliore per il proprio caso.
JohnAl

7
In sostanza, quello che stai chiedendo si chiama sparsità .
Mateen Ulhaq,

5
Sono necessarie ulteriori informazioni ... Perché l'accesso è casuale e i valori diversi da zero seguono uno schema?
Ext3h

4
@IwillnotexistIdonotexist Un passaggio di pre-calcolo andrebbe bene, ma l'array dovrebbe comunque essere modificato di volta in volta, quindi il passaggio di pre-calcolo non dovrebbe essere troppo costoso.
JohnAl,

Risposte:


155

Una semplice possibilità che viene in mente è quella di mantenere un array compresso di 2 bit per valore per i casi comuni e un 4 byte separato per valore (24 bit per l'indice dell'elemento originale, 8 bit per il valore effettivo, quindi (idx << 8) | value)) array ordinato per il gli altri.

Quando cerchi un valore, esegui prima una ricerca nell'array 2bpp (O (1)); se trovi 0, 1 o 2 è il valore che desideri; se ne trovi 3 significa che devi cercarlo nell'array secondario. Qui eseguirai una ricerca binaria per cercare l' indice di tuo interesse spostato a sinistra di 8 (O (log (n) con una piccola n, poiché dovrebbe essere l'1%), ed estrarre il valore dal 4 byte cosa.

std::vector<uint8_t> main_arr;
std::vector<uint32_t> sec_arr;

uint8_t lookup(unsigned idx) {
    // extract the 2 bits of our interest from the main array
    uint8_t v = (main_arr[idx>>2]>>(2*(idx&3)))&3;
    // usual (likely) case: value between 0 and 2
    if(v != 3) return v;
    // bad case: lookup the index<<8 in the secondary array
    // lower_bound finds the first >=, so we don't need to mask out the value
    auto ptr = std::lower_bound(sec_arr.begin(), sec_arr.end(), idx<<8);
#ifdef _DEBUG
    // some coherency checks
    if(ptr == sec_arr.end()) std::abort();
    if((*ptr >> 8) != idx) std::abort();
#endif
    // extract our 8-bit value from the 32 bit (index, value) thingie
    return (*ptr) & 0xff;
}

void populate(uint8_t *source, size_t size) {
    main_arr.clear(); sec_arr.clear();
    // size the main storage (round up)
    main_arr.resize((size+3)/4);
    for(size_t idx = 0; idx < size; ++idx) {
        uint8_t in = source[idx];
        uint8_t &target = main_arr[idx>>2];
        // if the input doesn't fit, cap to 3 and put in secondary storage
        if(in >= 3) {
            // top 24 bits: index; low 8 bit: value
            sec_arr.push_back((idx << 8) | in);
            in = 3;
        }
        // store in the target according to the position
        target |= in << ((idx & 3)*2);
    }
}

Per un array come quello che hai proposto, questo dovrebbe richiedere 10000000/4 = 2500000 byte per il primo array, più 10000000 * 1% * 4 B = 400000 byte per il secondo array; quindi 2900000 byte, ovvero meno di un terzo dell'array originale, e la porzione più utilizzata viene tenuta insieme nella memoria, il che dovrebbe essere buono per la memorizzazione nella cache (potrebbe anche adattarsi a L3).

Se hai bisogno di più di un indirizzamento a 24 bit, dovrai modificare la "memoria secondaria"; un modo banale per estenderlo è disporre di un array di puntatori a 256 elementi per passare dagli 8 bit principali dell'indice e inoltrare a un array ordinato indicizzato a 24 bit come sopra.


Benchmark rapido

#include <algorithm>
#include <vector>
#include <stdint.h>
#include <chrono>
#include <stdio.h>
#include <math.h>

using namespace std::chrono;

/// XorShift32 generator; extremely fast, 2^32-1 period, way better quality
/// than LCG but fail some test suites
struct XorShift32 {
    /// This stuff allows to use this class wherever a library function
    /// requires a UniformRandomBitGenerator (e.g. std::shuffle)
    typedef uint32_t result_type;
    static uint32_t min() { return 1; }
    static uint32_t max() { return uint32_t(-1); }

    /// PRNG state
    uint32_t y;

    /// Initializes with seed
    XorShift32(uint32_t seed = 0) : y(seed) {
        if(y == 0) y = 2463534242UL;
    }

    /// Returns a value in the range [1, 1<<32)
    uint32_t operator()() {
        y ^= (y<<13);
        y ^= (y>>17);
        y ^= (y<<15);
        return y;
    }

    /// Returns a value in the range [0, limit); this conforms to the RandomFunc
    /// requirements for std::random_shuffle
    uint32_t operator()(uint32_t limit) {
        return (*this)()%limit;
    }
};

struct mean_variance {
    double rmean = 0.;
    double rvariance = 0.;
    int count = 0;

    void operator()(double x) {
        ++count;
        double ormean = rmean;
        rmean     += (x-rmean)/count;
        rvariance += (x-ormean)*(x-rmean);
    }

    double mean()     const { return rmean; }
    double variance() const { return rvariance/(count-1); }
    double stddev()   const { return std::sqrt(variance()); }
};

std::vector<uint8_t> main_arr;
std::vector<uint32_t> sec_arr;

uint8_t lookup(unsigned idx) {
    // extract the 2 bits of our interest from the main array
    uint8_t v = (main_arr[idx>>2]>>(2*(idx&3)))&3;
    // usual (likely) case: value between 0 and 2
    if(v != 3) return v;
    // bad case: lookup the index<<8 in the secondary array
    // lower_bound finds the first >=, so we don't need to mask out the value
    auto ptr = std::lower_bound(sec_arr.begin(), sec_arr.end(), idx<<8);
#ifdef _DEBUG
    // some coherency checks
    if(ptr == sec_arr.end()) std::abort();
    if((*ptr >> 8) != idx) std::abort();
#endif
    // extract our 8-bit value from the 32 bit (index, value) thingie
    return (*ptr) & 0xff;
}

void populate(uint8_t *source, size_t size) {
    main_arr.clear(); sec_arr.clear();
    // size the main storage (round up)
    main_arr.resize((size+3)/4);
    for(size_t idx = 0; idx < size; ++idx) {
        uint8_t in = source[idx];
        uint8_t &target = main_arr[idx>>2];
        // if the input doesn't fit, cap to 3 and put in secondary storage
        if(in >= 3) {
            // top 24 bits: index; low 8 bit: value
            sec_arr.push_back((idx << 8) | in);
            in = 3;
        }
        // store in the target according to the position
        target |= in << ((idx & 3)*2);
    }
}

volatile unsigned out;

int main() {
    XorShift32 xs;
    std::vector<uint8_t> vec;
    int size = 10000000;
    for(int i = 0; i<size; ++i) {
        uint32_t v = xs();
        if(v < 1825361101)      v = 0; // 42.5%
        else if(v < 4080218931) v = 1; // 95.0%
        else if(v < 4252017623) v = 2; // 99.0%
        else {
            while((v & 0xff) < 3) v = xs();
        }
        vec.push_back(v);
    }
    populate(vec.data(), vec.size());
    mean_variance lk_t, arr_t;
    for(int i = 0; i<50; ++i) {
        {
            unsigned o = 0;
            auto beg = high_resolution_clock::now();
            for(int i = 0; i < size; ++i) {
                o += lookup(xs() % size);
            }
            out += o;
            int dur = (high_resolution_clock::now()-beg)/microseconds(1);
            fprintf(stderr, "lookup: %10d µs\n", dur);
            lk_t(dur);
        }
        {
            unsigned o = 0;
            auto beg = high_resolution_clock::now();
            for(int i = 0; i < size; ++i) {
                o += vec[xs() % size];
            }
            out += o;
            int dur = (high_resolution_clock::now()-beg)/microseconds(1);
            fprintf(stderr, "array:  %10d µs\n", dur);
            arr_t(dur);
        }
    }

    fprintf(stderr, " lookup |   ±  |  array  |   ±  | speedup\n");
    printf("%7.0f | %4.0f | %7.0f | %4.0f | %0.2f\n",
            lk_t.mean(), lk_t.stddev(),
            arr_t.mean(), arr_t.stddev(),
            arr_t.mean()/lk_t.mean());
    return 0;
}

(codice e dati sempre aggiornati nel mio Bitbucket)

Il codice sopra popola un array di elementi 10M con dati casuali distribuiti come OP specificato nel loro post, inizializza la mia struttura di dati e quindi:

  • esegue una ricerca casuale di 10 milioni di elementi con la mia struttura di dati
  • fa lo stesso attraverso l'array originale.

(notare che in caso di ricerca sequenziale l'array vince sempre in misura notevole, poiché è la ricerca più cache-friendly che puoi fare)

Questi ultimi due blocchi vengono ripetuti 50 volte e cronometrati; alla fine, vengono calcolate e stampate la deviazione media e standard per ciascun tipo di ricerca, insieme allo speedup (lookup_mean / array_mean).

Ho compilato il codice sopra con g ++ 5.4.0 ( -O3 -static, oltre ad alcuni avvertimenti) su Ubuntu 16.04, e l'ho eseguito su alcune macchine; la maggior parte di essi esegue Ubuntu 16.04, alcuni Linux più vecchi, alcuni Linux più recenti. Non penso che il sistema operativo dovrebbe essere rilevante in questo caso.

            CPU           |  cache   |  lookup s)   |     array s)  | speedup (x)
Xeon E5-1650 v3 @ 3.50GHz | 15360 KB |  60011 ±  3667 |   29313 ±  2137 | 0.49
Xeon E5-2697 v3 @ 2.60GHz | 35840 KB |  66571 ±  7477 |   33197 ±  3619 | 0.50
Celeron G1610T  @ 2.30GHz |  2048 KB | 172090 ±   629 |  162328 ±   326 | 0.94
Core i3-3220T   @ 2.80GHz |  3072 KB | 111025 ±  5507 |  114415 ±  2528 | 1.03
Core i5-7200U   @ 2.50GHz |  3072 KB |  92447 ±  1494 |   95249 ±  1134 | 1.03
Xeon X3430      @ 2.40GHz |  8192 KB | 111303 ±   936 |  127647 ±  1503 | 1.15
Core i7 920     @ 2.67GHz |  8192 KB | 123161 ± 35113 |  156068 ± 45355 | 1.27
Xeon X5650      @ 2.67GHz | 12288 KB | 106015 ±  5364 |  140335 ±  6739 | 1.32
Core i7 870     @ 2.93GHz |  8192 KB |  77986 ±   429 |  106040 ±  1043 | 1.36
Core i7-6700    @ 3.40GHz |  8192 KB |  47854 ±   573 |   66893 ±  1367 | 1.40
Core i3-4150    @ 3.50GHz |  3072 KB |  76162 ±   983 |  113265 ±   239 | 1.49
Xeon X5650      @ 2.67GHz | 12288 KB | 101384 ±   796 |  152720 ±  2440 | 1.51
Core i7-3770T   @ 2.50GHz |  8192 KB |  69551 ±  1961 |  128929 ±  2631 | 1.85

I risultati sono ... misti!

  1. In generale, sulla maggior parte di queste macchine c'è un qualche tipo di accelerazione, o almeno sono alla pari.
  2. I due casi in cui l'array supera davvero la ricerca della "struttura intelligente" si trovano su macchine con molta cache e non particolarmente occupate: Xeon E5-1650 sopra (15 MB di cache) è una macchina da costruzione notturna, al momento abbastanza inattiva; Xeon E5-2697 (35 MB di cache) è una macchina per calcoli ad alte prestazioni, anche in un momento di inattività. Ha senso, l'array originale si inserisce completamente nella loro enorme cache, quindi la struttura dei dati compatta aggiunge solo complessità.
  3. Sul lato opposto dello "spettro delle prestazioni" - ma dove l'array è leggermente più veloce, c'è l'umile Celeron che alimenta il mio NAS; ha una cache così piccola che né l'array né la "struttura intelligente" si adattano affatto. Altre macchine con cache abbastanza piccola funzionano in modo simile.
  4. Xeon X5650 deve essere preso con cautela: sono macchine virtuali su un server di macchine virtuali a doppio socket piuttosto occupato; può darsi che, sebbene nominalmente abbia una discreta quantità di cache, durante il tempo del test viene più volte anticipato da macchine virtuali completamente non correlate.

7
@JohnAl Non hai bisogno di una struttura. A uint32_tandrà bene. La cancellazione di un elemento dal buffer secondario lo lascerà ovviamente ordinato. L'inserimento di un elemento può essere fatto con std::lower_bounde poi insert(piuttosto che aggiungere e riordinare il tutto). Gli aggiornamenti rendono l'array secondario a dimensione intera molto più attraente - sicuramente inizierei con quello.
Martin Bonner sostiene Monica

6
@JohnAl Perché il valore è (idx << 8) + valche non devi preoccuparti della porzione di valore - basta usare un confronto diretto. Sarà sempre confrontare meno ((idx+1) << 8) + vale meno di((idx-1) << 8) + val
Martin Bonner sostiene Monica

3
@JohnAl: se ciò può essere utile, ho aggiunto una populatefunzione che dovrebbe essere popolata main_arre in sec_arrbase al formato che si lookupaspetta. In realtà non l'ho provato, quindi non mi aspetto che funzioni davvero correttamente :-); comunque, dovrebbe darti l'idea generale.
Matteo Italia,

6
Sto dando questo +1 solo per il benchmarking. Bello vedere una domanda sull'efficienza e con risultati anche per più tipi di processori! Bello!
Jack Aidley,

2
@JohnAI Dovresti profilarlo per il tuo caso d'uso reale e nient'altro. La velocità della stanza bianca non ha importanza.
Jack Aidley,

33

Un'altra opzione potrebbe essere

  • controlla se il risultato è 0, 1 o 2
  • in caso contrario esegui una ricerca regolare

In altre parole qualcosa del tipo:

unsigned char lookup(int index) {
    int code = (bmap[index>>2]>>(2*(index&3)))&3;
    if (code != 3) return code;
    return full_array[index];
}

dove bmaputilizza 2 bit per elemento con il valore 3 che significa "altro".

Questa struttura è banale da aggiornare, utilizza il 25% di memoria in più ma la maggior parte viene cercata solo nel 5% dei casi. Naturalmente, come al solito, se è una buona idea o meno dipende da molte altre condizioni, quindi l'unica risposta è la sperimentazione di un utilizzo reale.


4
Direi che è un buon compromesso per ottenere il maggior numero possibile di accessi alla cache (poiché la struttura ridotta può adattarsi più facilmente alla cache), senza perdere molto sul tempo di accesso casuale.
meneldal

Penso che questo possa essere ulteriormente migliorato. Ho avuto successo in passato con un problema simile ma diverso, in cui lo sfruttamento della previsione del ramo ha aiutato molto. Può aiutare a dividere if(code != 3) return code;inif(code == 0) return 0; if(code==1) return 1; if(code == 2) return 2;
kutschkem

@kutschkem: in tal caso, __builtin_expect& co o PGO possono anche aiutare.
Matteo Italia,

23

Questo è più un "lungo commento" che una risposta concreta

A meno che i tuoi dati non siano qualcosa di ben noto, dubito che chiunque possa rispondere DIRETTAMENTE alla tua domanda (e non sono a conoscenza di nulla che corrisponda alla tua descrizione, ma poi non so TUTTO su tutti i tipi di modelli di dati per tutti tipi di casi d'uso). I dati sparsi sono un problema comune nel calcolo ad alte prestazioni, ma in genere sono "abbiamo un array molto grande, ma solo alcuni valori sono diversi da zero".

Per schemi non ben noti come quello che penso sia il tuo, nessuno saprà direttamente qual è il migliore, e dipende dai dettagli: quanto è casuale l'accesso casuale - il sistema accede a gruppi di elementi di dati o è completamente casuale come da un generatore di numeri casuali uniforme. I dati della tabella sono completamente casuali o ci sono sequenze di 0 quindi sequenze di 1, con una dispersione di altri valori? La codifica della lunghezza di esecuzione funzionerebbe bene se si hanno sequenze ragionevolmente lunghe di 0 e 1, ma non funzionerà se si dispone di "scacchiera di 0/1". Inoltre, dovresti tenere una tabella di "punti di partenza", in modo da poter arrivare rapidamente al luogo pertinente abbastanza rapidamente.

So da molto tempo che alcuni grandi database sono solo una grande tabella nella RAM (dati di sottoscrizione degli scambi telefonici in questo esempio) e uno dei problemi è che le ottimizzazioni delle cache e delle tabelle delle pagine nel processore sono piuttosto inutili. Il chiamante è così raramente lo stesso di quello che ha recentemente chiamato qualcuno, che non ci sono dati precaricati di alcun tipo, è semplicemente puramente casuale. Le grandi tabelle di pagine sono la migliore ottimizzazione per quel tipo di accesso.

In molti casi, scendere a compromessi tra "velocità e dimensioni ridotte" è una di quelle cose che devi scegliere nell'ingegneria del software [in altre ingegneria non è necessariamente un compromesso]. Pertanto, "sprecare memoria per un codice più semplice" è spesso la scelta preferita. In questo senso, la soluzione "semplice" è probabilmente migliore per la velocità, ma se si utilizza "meglio" per la RAM, l'ottimizzazione per le dimensioni della tabella offre prestazioni sufficienti e un buon miglioramento delle dimensioni. Esistono molti modi diversi per raggiungere questo obiettivo - come suggerito in un commento, un campo a 2 bit in cui sono memorizzati i due o tre valori più comuni e quindi alcuni formati di dati alternativi per gli altri valori - una tabella hash sarebbe la mia primo approccio, ma potrebbe funzionare anche un elenco o un albero binario: di nuovo, dipende dagli schemi di dove sono i tuoi "non 0, 1 o 2". Ancora una volta, dipende da come i valori sono "sparsi" nella tabella - sono in gruppi o sono più di uno schema uniformemente distribuito?

Ma un problema è che stai ancora leggendo i dati dalla RAM. Quindi stai spendendo più codice per elaborare i dati, incluso un po 'di codice per far fronte a "questo non è un valore comune".

Il problema con gli algoritmi di compressione più comuni è che si basano su sequenze di decompressione, quindi non è possibile accedervi in ​​modo casuale. E il sovraccarico di dividere i big data in blocchi di, diciamo, 256 voci alla volta e decomprimere i 256 in un array uint8_t, recuperare i dati desiderati e quindi gettare via i dati non compressi, è altamente improbabile che ti dia del bene performance - supponendo che abbia una certa importanza, ovviamente.

Alla fine, probabilmente dovrai implementare una o alcune delle idee nei commenti / risposte per testare, vedere se aiuta a risolvere il problema o se il bus di memoria è ancora il principale fattore limitante.


Grazie! Alla fine, sono solo interessato a cosa è più veloce quando il 100% della CPU è impegnato a eseguire il loop su tali array (thread diversi su array diversi). Attualmente, con un uint8_tarray, la larghezza di banda RAM è satura dopo che ~ 5 thread stanno lavorando su quello contemporaneamente (su un sistema a quattro canali), quindi l'utilizzo di più di 5 thread non offre più alcun vantaggio. Vorrei che questo usasse> 10 thread senza incorrere in problemi di larghezza di banda RAM, ma se il lato CPU dell'accesso diventa così lento che 10 thread ottengono meno di 5 thread prima, ovviamente non sarebbe un progresso.
JohnAl

@JohnAl Quanti core hai? Se sei associato alla CPU, non ha senso avere più thread che core. Inoltre, forse è tempo di esaminare la programmazione della GPU?
Martin Bonner supporta Monica

@MartinBonner Attualmente ho 12 thread. E sono d'accordo, questo probabilmente funzionerebbe molto bene su una GPU.
JohnAl

2
@JohnAI: se stai semplicemente eseguendo più versioni dello stesso processo inefficiente su più thread, vedrai sempre progressi limitati. Ci saranno maggiori vittorie nella progettazione del tuo algoritmo per l'elaborazione parallela rispetto alla modifica di una struttura di archiviazione.
Jack Aidley,

13

Quello che ho fatto in passato è usare una hashmap davanti a un bitset.

Questo dimezza lo spazio rispetto alla risposta di Matteo, ma può essere più lento se le ricerche "di eccezione" sono lente (ovvero ci sono molte eccezioni).

Spesso, tuttavia, "cache is king".


2
In che modo una hashmap potrebbe dimezzare lo spazio rispetto alla risposta di Matteo ? Cosa dovrebbe essere in quella hashmap?
JohnAl

1
@JohnAl Utilizzo di un bitset 1 bit = bitvec anziché un bitvec 2 bit.
o11c,

2
@ o11c Non sono sicuro di averlo capito correttamente. Intendi avere una matrice di valori a 1 bit dove 0significa guardaremain_arr e 1significa guardaresec_arr (nel caso del codice Matteos)? Ciò richiederebbe complessivamente più spazio della risposta di Matteos, dal momento che è un array aggiuntivo. Non capisco bene come lo faresti usando solo metà dello spazio rispetto alla risposta di Matteos.
JohnAl

1
Potresti chiarire questo? Cerchi prima i casi aspettativi e poi guardi nella bitmap? In tal caso, sospetto che la ricerca lenta nell'hash travolgerà i risparmi nel ridurre le dimensioni della bitmap.
Martin Bonner supporta Monica

Pensavo che questo si chiamasse hashlinking, ma google non presenta hit rilevanti, quindi deve essere qualcos'altro. Il modo in cui di solito funzionava era dire un array di byte che contenesse valori la maggior parte dei quali, diciamo, erano compresi tra 0 e 254. Quindi useresti 255 come flag e se avessi un elemento 255 cercherai il valore vero in una tabella hash associata. Qualcuno può ricordare come si chiamava? (Penso di averlo letto in un vecchio IBM TR.) Comunque, potresti anche sistemarlo come suggerisce @ o11c - cerca sempre prima nell'hash, se non è lì, guarda nel tuo array di bit.
davidbak,

11

A meno che non ci sia un modello nei tuoi dati, è improbabile che ci sia una ragionevole velocità o ottimizzazione delle dimensioni e, supponendo che tu stia prendendo di mira un normale computer, 10 MB non sono comunque un grosso problema.

Ci sono due ipotesi nelle tue domande:

  1. I dati vengono archiviati in modo scadente perché non si utilizzano tutti i bit
  2. Memorizzarlo meglio renderebbe le cose più veloci.

Penso che entrambi questi presupposti siano falsi. Nella maggior parte dei casi, il modo appropriato per archiviare i dati è quello di archiviare la rappresentazione più naturale. Nel tuo caso, questo è quello che hai scelto: un byte per un numero compreso tra 0 e 255. Qualsiasi altra rappresentazione sarà più complessa e quindi - a parità di altre condizioni - più lenta e più soggetta a errori. Per deviare da questo principio generale è necessario un motivo più forte di potenzialmente sei bit "sprecati" sul 95% dei dati.

Per la tua seconda ipotesi, sarà vero se, e solo se, la modifica della dimensione dell'array comporterà un numero sostanzialmente inferiore di errori nella cache. Se ciò accadrà può essere determinato in modo definitivo solo tramite la profilazione del codice di lavoro, ma penso che sia altamente improbabile fare una differenza sostanziale. Poiché accederai casualmente all'array in entrambi i casi, il processore farà fatica a sapere quali bit di dati memorizzare nella cache e conservare in entrambi i casi.


8

Se i dati e gli accessi vengono distribuiti in modo uniforme in modo casuale, le prestazioni dipenderanno probabilmente da quale frazione di accessi eviterà una perdita di cache di livello esterno. L'ottimizzazione richiede la conoscenza delle dimensioni dell'array che può essere sistemato in modo affidabile nella cache. Se la cache è abbastanza grande da contenere un byte per ogni cinque celle, l'approccio più semplice potrebbe essere quello di avere un byte che contenga i cinque valori codificati base-tre nell'intervallo 0-2 (ci sono 243 combinazioni di 5 valori, quindi inserirsi in un byte), insieme a un array di 10.000.000 byte che verrebbe interrogato ogni volta che un valore di base-3 indica "2".

Se la cache non fosse così grande, ma potesse contenere un byte per 8 celle, non sarebbe possibile utilizzare un valore byte per selezionare tra tutte le 6.561 possibili combinazioni di otto valori base-3, ma poiché l'unico effetto di la modifica di uno 0 o 1 in 2 significherebbe causare una ricerca altrimenti non necessaria, la correttezza non richiederebbe il supporto di tutti i 6.561. Invece, si potrebbe concentrarsi sui 256 valori più "utili".

Soprattutto se 0 è più comune di 1, o viceversa, un buon approccio potrebbe essere quello di utilizzare 217 valori per codificare le combinazioni di 0 e 1 che contengono 5 o meno 1, 16 valori per codificare da xxxx0000 a xxxx1111, 16 per codificare da 0000xxxx a 1111xxxx e uno per xxxxxxxx. Resterebbero quattro valori per qualunque altro uso si possa trovare. Se i dati vengono distribuiti casualmente come descritto, una leggera maggioranza di tutte le query colpirà byte che contenevano solo zero e uno (in circa 2/3 di tutti i gruppi di otto, tutti i bit sarebbero zero e uno e circa 7/8 di quelli avrebbero sei o meno 1 bit); la stragrande maggioranza di coloro che non sarebbero atterrati in un byte che conteneva quattro x e avrebbe una probabilità del 50% di atterrare su uno zero o uno. Pertanto, solo circa una query su quattro richiederebbe una ricerca di array di grandi dimensioni.

Se i dati vengono distribuiti in modo casuale ma la cache non è abbastanza grande per gestire un byte per otto elementi, si potrebbe provare a utilizzare questo approccio con ogni byte che gestisce più di otto elementi, ma a meno che non vi sia un forte orientamento verso 0 o verso 1 , la frazione di valori che possono essere gestiti senza dover effettuare una ricerca nella matrice grande si ridurrà all'aumentare del numero gestito da ciascun byte.


7

Aggiungerò alla risposta di @ o11c , poiché la sua formulazione potrebbe essere un po 'confusa. Se devo spremere l'ultimo bit e il ciclo della CPU farei quanto segue.

Inizieremo costruendo un albero di ricerca binaria bilanciato che contiene il 5% di casi "qualcos'altro". Per ogni ricerca, percorri rapidamente l'albero: hai 10000000 elementi: il 5% dei quali si trova nell'albero: quindi la struttura dei dati dell'albero contiene 500000 elementi. Camminando questo in O (log (n)) tempo, ti dà 19 iterazioni. Non sono esperto in questo, ma suppongo che ci siano alcune implementazioni efficienti in termini di memoria. Indoviniamo:

  • Albero bilanciato, quindi è possibile calcolare la posizione della sottostruttura (non è necessario che gli indici siano memorizzati nei nodi dell'albero). Allo stesso modo un heap (struttura dati) viene archiviato nella memoria lineare.
  • Valore di 1 byte (da 2 a 255)
  • 3 byte per l'indice (10000000 richiede 23 bit, che corrisponde a 3 byte)

Totale, 4 byte: 500000 * 4 = 1953 kB. Si adatta alla cache!

Per tutti gli altri casi (0 o 1), è possibile utilizzare un bitvector. Si noti che non è possibile escludere il 5% di altri casi per l'accesso casuale: 1,19 MB.

La combinazione di questi due utilizza circa 3.099 MB. Usando questa tecnica, risparmierai un fattore 3.08 di memoria.

Tuttavia, questo non batte la risposta di @Matteo Italia (che utilizza 2,76 MB), peccato. C'è qualcosa che possiamo fare di più? La parte che consuma più memoria sono i 3 byte di indice nella struttura. Se riusciamo a portare questo a 2, risparmieremmo 488 kB e l'utilizzo totale della memoria sarebbe: 2.622 MB, che è più piccolo!

Come facciamo questo? Dobbiamo ridurre l'indicizzazione a 2 byte. Ancora una volta, 10000000 richiede 23 bit. Dobbiamo essere in grado di rilasciare 7 bit. Possiamo semplicemente farlo partizionando l'intervallo di 10000000 elementi in 2 ^ 7 (= 128) regioni di 78125 elementi. Ora possiamo costruire un albero bilanciato per ognuna di queste regioni, con 3906 elementi in media. La scelta dell'albero giusto viene effettuata da una semplice divisione dell'indice di destinazione per 2 ^ 7 (o un bit >> 7- shift ). Ora l'indice richiesto da memorizzare può essere rappresentato dai restanti 16 bit. Si noti che esiste un sovraccarico per la lunghezza dell'albero che deve essere memorizzato, ma questo è trascurabile. Si noti inoltre che questo meccanismo di suddivisione riduce il numero richiesto di iterazioni per camminare sull'albero, questo ora riduce a 7 iterazioni in meno, perché abbiamo lasciato cadere 7 bit: rimangono solo 12 iterazioni.

Si noti che si potrebbe teoricamente ripetere il processo per tagliare i successivi 8 bit, ma ciò richiederebbe la creazione di 2 ^ 15 alberi bilanciati, con circa 305 elementi in media. Ciò comporterebbe 2.143 MB, con solo 4 iterazioni per camminare sull'albero, il che è un notevole aumento di velocità, rispetto alle 19 iterazioni con cui abbiamo iniziato.

Come conclusione finale: questo batte la strategia vettoriale a 2 bit con un po 'di utilizzo della memoria, ma è una vera lotta da implementare. Ma se può fare la differenza tra il montaggio della cache o meno, potrebbe valere la pena provare.


1
Sforzo valoroso!
davidbak,

1
Prova questo: poiché il 4% dei casi è il valore 2 ... crea un insieme di casi eccezionali (> 1). Crea un albero un po 'come descritto per casi davvero eccezionali (> 2). Se presente in set e tree, usa value in tree; se presente nel set e non nella struttura ad albero, usa il valore 2, altrimenti (non presente nel set) cerca nel tuo bitvector. L'albero conterrà solo 100000 elementi (byte). Il set contiene 500000 elementi (ma nessun valore). Ciò riduce le dimensioni giustificando al contempo un aumento dei costi? (Il 100% delle ricerche appare nel set; il 5% delle ricerche deve cercare anche nell'albero.)
davidbak,

Devi sempre usare un array ordinato CFBS quando hai un albero immutabile, quindi non c'è allocazione per i nodi, solo i dati.
o11c,

5

Se si eseguono solo operazioni di lettura, sarebbe meglio non assegnare un valore a un singolo indice ma a un intervallo di indici.

Per esempio:

[0, 15000] = 0
[15001, 15002] = 153
[15003, 26876] = 2
[25677, 31578] = 0
...

Questo può essere fatto con una struttura. Potresti anche voler definire una classe simile a questa se ti piace un approccio OO.

class Interval{
  private:
    uint32_t start; // First element of interval
    uint32_t end; // Last element of interval
    uint8_t value; // Assigned value

  public:
    Interval(uint32_t start, uint32_t end, uint8_t value);
    bool isInInterval(uint32_t item); // Checks if item lies within interval
    uint8_t getValue(); // Returns the assigned value
}

Ora devi solo scorrere un elenco di intervalli e verificare se il tuo indice si trova all'interno di uno di essi, che in media può richiedere molta meno memoria, ma costa più risorse della CPU.

Interval intervals[INTERVAL_COUNT];
intervals[0] = Interval(0, 15000, 0);
intervals[1] = Interval(15001, 15002, 153);
intervals[2] = Interval(15003, 26876, 2);
intervals[3] = Interval(25677, 31578, 0);
...

uint8_t checkIntervals(uint32_t item)

    for(int i=0; i<INTERVAL_COUNT-1; i++)
    {
        if(intervals[i].isInInterval(item) == true)
        {
            return intervals[i].getValue();
        }
    }
    return DEFAULT_VALUE;
}

Se ordini gli intervalli in base alla dimensione decrescente, aumenti la probabilità che l'elemento che stai cercando venga trovato in anticipo, il che riduce ulteriormente la memoria media e l'utilizzo delle risorse della CPU.

Puoi anche rimuovere tutti gli intervalli con una dimensione di 1. Inserisci i valori corrispondenti in una mappa e controllali solo se l'elemento che stai cercando non è stato trovato negli intervalli. Ciò dovrebbe anche aumentare leggermente le prestazioni medie.


4
Idea interessante (+1) ma sono un po 'scettico sul fatto che giustificherebbe il sovraccarico a meno che non ci siano molte lunghe serie di 0 e / o lunghe serie di 1. In effetti, stai suggerendo di utilizzare una codifica di lunghezza dei dati. Potrebbe essere buono in alcune situazioni, ma probabilmente non è un buon approccio generale a questo problema.
John Coleman,

Destra. In particolare per l'accesso casuale, questo è quasi certamente più lento di un semplice array o unt8_t, anche se richiede molta meno memoria.
lasciato

4

Molto tempo fa, posso solo ricordare ...

All'università ci è stato affidato il compito di accelerare un programma di ray tracer, che deve leggere più volte tramite algoritmo da array di buffer. Un amico mi ha detto di usare sempre letture RAM che sono multipli di 4 byte. Quindi ho cambiato l'array da un modello di [x1, y1, z1, x2, y2, z2, ..., xn, yn, zn] in un modello di [x1, y1, z1,0, x2, y2, z2 , 0, ..., xn, yn, Zn, 0]. Significa che aggiungo un campo vuoto dopo ogni coordinata 3D. Dopo alcuni test delle prestazioni: è stato più veloce. Per farla breve: leggi multipli di 4 byte dal tuo array dalla RAM, e forse anche dalla giusta posizione iniziale, quindi leggi un piccolo cluster in cui si trova l'indice cercato e leggi l'indice cercato da questo piccolo cluster in cpu. (Nel tuo caso non dovrai inserire campi di riempimento, ma il concetto dovrebbe essere chiaro)

Forse anche altri multipli potrebbero essere la chiave nei sistemi più recenti.

Non so se questo funzionerà nel tuo caso, quindi se non funziona: mi dispiace. Se funziona sarei felice di conoscere alcuni risultati del test.

PS: Oh, e se c'è qualche modello di accesso o indici vicini, puoi riutilizzare il cluster memorizzato nella cache.

PPS: Potrebbe essere che il fattore multiplo fosse più simile a 16 byte o qualcosa del genere, è troppo tempo fa, che posso ricordare esattamente.


Probabilmente stai pensando alle cache, che di solito sono 32 o 64 byte, ma che non ti aiuteranno molto in quanto l'accesso è casuale.
Surt

3

Guardando questo, potresti dividere i tuoi dati, ad esempio:

  • un bitset che viene indicizzato e rappresenta il valore 0 (std :: vector sarebbe utile qui)
  • un bitset che viene indicizzato e rappresenta il valore 1
  • uno std :: vector per i valori di 2, contenente gli indici che si riferiscono a questo valore
  • una mappa per gli altri valori (o std :: vector>)

In questo caso, tutti i valori vengono visualizzati fino a un determinato indice, quindi è anche possibile rimuovere uno dei bitset e rappresentare il valore in quanto mancante negli altri.

Questo ti farà risparmiare un po 'di memoria per questo caso, anche se peggiorerebbe il caso peggiore. Avrai anche bisogno di più potenza della CPU per eseguire le ricerche.

Assicurati di misurare!


1
Un bitset per uni / zeri. Un insieme di indici per due. E una schiera matrice associativa per il resto.
Red.Wave

Questo è il breve riassunto
JVApen,

Fai conoscere all'OP i termini, in modo che possa cercare implementazioni alternative di ciascuno.
Rosso

2

Come Mats menziona nella sua risposta al commento, è difficile dire quale sia effettivamente la soluzione migliore senza sapere specificamente quale tipo di dati hai (ad esempio, ci sono lunghe serie di 0 e così via) e quale aspetto ha il tuo modello di accesso come (significa "casuale" significa "ovunque" o semplicemente "non rigorosamente in modo completamente lineare" o "ogni valore esattamente una volta, solo randomizzato" o ...).

Detto questo, ci sono due meccanismi che vengono in mente:

  • Matrici di bit; cioè, se avessi solo due valori, potresti comprimere banalmente l'array di un fattore 8; se hai 4 valori (o "3 valori + tutto il resto") puoi comprimerlo di un fattore due. Il che potrebbe non valere la pena e che avrebbe bisogno di parametri di riferimento, soprattutto se si hanno modelli di accesso davvero casuali che sfuggono alle cache e quindi non cambiano affatto il tempo di accesso.
  • (index,value)o (value,index)tabelle. Vale a dire, ha una tabella molto piccola per il caso dell'1%, forse una tabella per il caso del 5% (che deve solo memorizzare gli indici poiché tutti hanno lo stesso valore) e un array di bit compresso grande per gli ultimi due casi. E con "tabella" intendo qualcosa che consente una ricerca relativamente rapida; cioè, forse un hash, un albero binario e così via, a seconda di ciò che hai a disposizione e delle tue reali esigenze. Se questi sottotitoli si adattano alle tue cache di 1 ° / 2 ° livello, potresti essere fortunato.

1

Non ho molta familiarità con C, ma in C ++ puoi usare il carattere senza segno per rappresentare un numero intero compreso tra 0 e 255.

Rispetto al normale int (di nuovo, vengo dal mondo Java e C ++ ) in cui sono richiesti 4 byte (32 bit), un carattere senza segno richiede 1 byte (8 bit). quindi potrebbe ridurre le dimensioni totali dell'array del 75%.


Questo è probabilmente già il caso dell'uso di uint8_t - 8 significa 8 bit.
Peter Mortensen,

-4

Hai brevemente descritto tutte le caratteristiche di distribuzione del tuo array; lancia l'array .

È possibile sostituire facilmente l'array con un metodo randomizzato che produce lo stesso output probabilistico dell'array.

Se la coerenza è importante (producendo lo stesso valore per lo stesso indice casuale), considera l'utilizzo di un filtro bloom e / o di una mappa hash per tenere traccia dei successi ripetuti. Se gli accessi all'array sono davvero casuali, tuttavia, questo è totalmente inutile.


18
Sospetto che qui sia stato usato "accesso casuale" per indicare che gli accessi sono imprevedibili, non che siano effettivamente casuali. (ovvero inteso nel senso di "file ad accesso casuale")
Michael Kay,

Sì, è probabile. OP non è chiaro, tuttavia. Se gli accessi di OP non sono in alcun modo casuali, viene indicata una qualche forma di matrice sparsa, come per le altre risposte.
Dúthomhas,

1
Penso che tu abbia un punto lì, dal momento che l'OP ha indicato che avrebbe passato l'intero array in un ordine casuale. Nel caso in cui si debbano osservare solo le distribuzioni, questa è una buona risposta.
Ingo Schalk-Schupp,
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.