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!
- In generale, sulla maggior parte di queste macchine c'è un qualche tipo di accelerazione, o almeno sono alla pari.
- 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à.
- 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.
- 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.