Una posibilidad simple que viene a la mente es mantener una matriz comprimida de 2 bits por valor para los casos comunes, y un byte separado de 4 bytes por valor (24 bits para el índice del elemento original, 8 bits para el valor real, entonces (idx << 8) | value)
) matriz ordenada para el otros.
Cuando busca un valor, primero realiza una búsqueda en la matriz de 2bpp (O (1)); si encuentra 0, 1 o 2, es el valor que desea; si encuentra 3 significa que debe buscarlo en la matriz secundaria. Aquí realizará una búsqueda binaria para buscar el índice de su interés desplazado a la izquierda por 8 (O (log (n) con una pequeña n, ya que este debería ser el 1%), y extraer el valor del 4- byte cosita
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);
}
}
Para una matriz como la que propuso, esto debería tomar 10000000/4 = 2500000 bytes para la primera matriz, más 10000000 * 1% * 4 B = 400000 bytes para la segunda matriz; por lo tanto, 2900000 bytes, es decir, menos de un tercio de la matriz original, y la porción más utilizada se mantiene unida en la memoria, lo que debería ser bueno para el almacenamiento en caché (incluso puede caber en L3).
Si necesita un direccionamiento de más de 24 bits, deberá modificar el "almacenamiento secundario"; Una forma trivial de extenderlo es tener una matriz de puntero de 256 elementos para cambiar los 8 bits superiores del índice y reenviar a una matriz ordenada indexada de 24 bits como se indicó anteriormente.
Punto de referencia rápido
#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;
}
(código y datos siempre actualizados en mi Bitbucket)
El código anterior llena una matriz de elementos de 10M con datos aleatorios distribuidos como OP especificado en su publicación, inicializa mi estructura de datos y luego:
- realiza una búsqueda aleatoria de elementos de 10M con mi estructura de datos
- hace lo mismo a través de la matriz original.
(tenga en cuenta que en caso de búsqueda secuencial, la matriz siempre gana en gran medida, ya que es la búsqueda más amigable para la caché que puede hacer)
Estos dos últimos bloques se repiten 50 veces y se cronometran; al final, la desviación media y estándar para cada tipo de búsqueda se calcula e imprime, junto con la aceleración (lookup_mean / array_mean).
Compilé el código anterior con g ++ 5.4.0 ( -O3 -static
, más algunas advertencias) en Ubuntu 16.04, y lo ejecuté en algunas máquinas; la mayoría de ellos ejecutan Ubuntu 16.04, algunos algunos Linux más antiguos, otros algunos Linux más nuevos. No creo que el sistema operativo deba ser relevante en este 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
¡Los resultados son ... mixtos!
- En general, en la mayoría de estas máquinas hay algún tipo de aceleración, o al menos están a la par.
- Los dos casos en los que la matriz realmente supera la búsqueda de "estructura inteligente" son en máquinas con mucho caché y no particularmente ocupadas: el Xeon E5-1650 anterior (15 MB de caché) es una máquina de construcción nocturna, en este momento bastante inactiva; Xeon E5-2697 (35 MB de caché) es una máquina para cálculos de alto rendimiento, también en un momento de inactividad. Tiene sentido, la matriz original encaja completamente en su enorme caché, por lo que la estructura de datos compacta solo agrega complejidad.
- En el lado opuesto del "espectro de rendimiento", pero donde nuevamente la matriz es un poco más rápida, está el humilde Celeron que alimenta mi NAS; tiene tan poco caché que ni la matriz ni la "estructura inteligente" encajan en absoluto. Otras máquinas con caché lo suficientemente pequeña funcionan de manera similar.
- El Xeon X5650 debe tomarse con precaución: son máquinas virtuales en un servidor de máquina virtual de doble socket bastante ocupado; bien puede ser que, aunque nominalmente tiene una cantidad decente de caché, durante el tiempo de la prueba es reemplazado por máquinas virtuales completamente independientes varias veces.