Будь-яка оптимізація для випадкового доступу на дуже великому масиві, коли значення в 95% випадків дорівнює 0 або 1?


133

Чи можлива оптимізація випадкового доступу на дуже великому масиві (я зараз використовую uint8_tі запитую про те, що краще)

uint8_t MyArray[10000000];

коли значення в будь-якій позиції масиву є

  • 0 або 1 для 95% усіх випадків,
  • 2 у 4% випадків,
  • між 3 і 255 в інших 1% випадків?

Отже, чи є для цього щось краще, ніж uint8_tмасив? Потрібно якомога швидше перебирати весь масив у довільному порядку, і це дуже важко для пропускної здатності оперативної пам’яті, тому при наявності більш ніж декількох потоків, які роблять це одночасно для різних масивів, в даний час вся пропускна здатність оперативної пам’яті швидко насичується.

Я запитую, бо відчувати себе дуже неефективно мати такий великий масив (10 Мб), коли насправді відомо, що майже всі значення, крім 5%, будуть або 0, або 1. Так, коли 95% усіх значень у масиві фактично знадобиться лише 1 біт замість 8 біт, це зменшить використання пам'яті майже на порядок. Складається враження, що повинно бути ефективніше пам'ять, яке значно зменшить пропускну здатність оперативної пам'яті, необхідну для цього, і, як результат, буде значно швидше для випадкового доступу.


36
Два біта (0/1 / див. Хешбел) та хештел для значень, більших за 1?
користувач253751

6
@ user202729 Від чого це залежить? Я думаю, що це щось цікаве питання для кожного, хто повинен зробити щось подібне, як я, тому я хотів би бачити більше універсального рішення для цього, а не відповідь, який є супер специфічним для мого коду. Якщо це залежить від чогось, було б добре відповісти, що пояснює, від чого це залежить, щоб кожен, хто його читав, міг зрозуміти, чи є краще рішення для його власної справи.
JohnAl

7
По суті, те, що ви запитуєте, називається рідкістю .
Mateen Ulhaq

5
Потрібна додаткова інформація ... Чому доступ випадковий і чи ненульові значення відповідають шаблону?
Ext3h

4
@IwillnotexistIdonotexist Крок попереднього обчислення буде добре, але масив все одно час від часу слід змінювати, тому крок попереднього обчислення не повинен бути надто дорогим.
ДжонАль

Відповіді:


155

Проста можливість, яка спадає на думку, полягає в тому, щоб утримувати стислий масив у 2 біти на значення для загальних випадків і відокремлений 4 байти на значення (24 біт для вихідного індексу елемента, 8 біт для фактичного значення, так (idx << 8) | value)) відсортований масив для інші.

Коли ви шукаєте значення, спочатку робите пошук у масиві 2bpp (O (1)); якщо ви знайдете 0, 1 або 2 - це значення, яке ви хочете; якщо ви знайдете 3, це означає, що вам доведеться шукати його у вторинному масиві. Тут ви будете виконувати двійковий пошук, щоб шукати індекс вашої зацікавленості, зсунутий вліво на 8 (O (log (n) з малим n, оскільки це має бути 1%), і витягувати значення з 4- byte thingie.

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);
    }
}

Для такого масиву, як той, який ви запропонували, для першого масиву має бути 10000000/4 = 2500000 байт, плюс 10000000 * 1% * 4 B = 400000 байт для другого масиву; отже, 2900000 байт, тобто менше третини вихідного масиву, а найбільш використовувана частина зберігається разом у пам'яті, що повинно бути корисним для кешування (це може навіть відповідати L3).

Якщо вам потрібно більше 24-бітної адреси, вам доведеться налаштувати "вторинне сховище"; тривіальним способом її розширення є наявність вказівного масиву 256 елементів для перемикання на 8 верхніх бітів індексу та перехід до 24-бітового індексованого відсортованого масиву, як зазначено вище.


Швидкий орієнтир

#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;
}

(код і дані завжди оновлюються в моєму Bitbucket)

Код вище заповнює 10-елементний масив з випадковими даними, розподіленими як ОР, зазначені в їх публікації, ініціалізує мою структуру даних, а потім:

  • виконує випадковий пошук 10M елементів за допомогою моєї структури даних
  • робить те ж саме через вихідний масив.

(зауважте, що у випадку послідовного пошуку масив завжди виграє величезною мірою, оскільки це найбільш зручний кеш-пошук, який ви можете зробити)

Ці останні два блоки повторюються 50 разів і приурочуються до часу; наприкінці середнє та стандартне відхилення для кожного типу пошуку обчислюються та друкуються разом із швидкістю (lookup_mean / array_mean).

Я склав код вище за допомогою g ++ 5.4.0 ( -O3 -staticплюс деякі попередження) на Ubuntu 16.04 і запустив його на деяких машинах; Більшість з них працює з Ubuntu 16.04, дехто з старими Linux, дехто з новішим Linux. Я не думаю, що ОС повинна взагалі бути актуальною.

            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

Результати ... змішані!

  1. Загалом, на більшості цих машин є якась швидкість, або принаймні вони нарівні.
  2. Два випадки, коли масив справді перемагає пошук "розумної структури", знаходиться на машинах з великою кількістю кешу і не особливо зайнятий: Xeon E5-1650 вище (кеш-пам'ять 15 Мб) - це нічна машина для побудови, на даний момент досить простоюча; Xeon E5-2697 (35 МБ кеш-пам'яті) - це машина для високоефективних обчислень і в холостий момент. Це має сенс, оригінальний масив повністю вписується у їх величезний кеш, тому компактна структура даних лише додає складності.
  3. На протилежному боці "спектру продуктивності" - але там, де знову масив трохи швидший, є скромний Celeron, який живить мій NAS; у ньому є так мало кешу, що ні масив, ні "розумна структура" взагалі не вміщуються в ньому. Інші машини з кешем досить маленькі.
  4. Xeon X5650 потрібно сприймати з обережністю - це віртуальні машини на досить зайнятому сервері віртуальної машини з подвійною розеткою; цілком можливо, що, хоча номінально він має пристойну кількість кешу, за час тестування його кілька разів випробовують абсолютно незв'язані віртуальні машини.

7
@JohnAl Вам не потрібна структура. А uint32_tбуде добре. Стираючи елемент із вторинного буфера, очевидно, його буде відсортовано. Вставлення елемента можна виконати за допомогою std::lower_boundта потім insert(а не додавати та переробляти всю річ). Оновлення роблять вторинний масив у повному розмірі набагато привабливішим - я, безумовно, розпочну з цього.
Мартін Боннер підтримує Моніку

6
@JohnAl Оскільки це значення, (idx << 8) + valвам не доведеться турбуватися про значення вартості - просто використовуйте пряме порівняння. Це завжди буде порівнювати менше ((idx+1) << 8) + valі менше, ніж((idx-1) << 8) + val
Мартін Боннер підтримує Моніку

3
@JohnAl: якщо це може бути корисним, я додав populateфункцію, яка повинна заповнюватись main_arrі sec_arrвідповідно до очікуваного формату lookup. Я насправді не пробував цього, тому не сподівайтеся, що він справді працює правильно :-); так чи інакше, це повинно дати вам загальну думку.
Маттео Італія

6
Я даю цю оцінку +1 лише для тестування. Приємно бачити питання щодо ефективності та результатів для кількох типів процесора! Приємно!
Джек Едлі

2
@JohnAI Ви повинні профайлювати його для фактичного випадку використання та нічого іншого. Швидкість білої кімнати не має значення.
Джек Едлі

33

Інший варіант може бути

  • перевірте, чи результат 0, 1 або 2
  • якщо не робити звичайний пошук

Іншими словами:

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

де bmapвикористовується 2 біти на елемент зі значенням 3, що означає "інше".

Ця структура є тривіальною для оновлення, використовує на 25% більше пам’яті, але більша частина шукається лише у 5% випадків. Звичайно, як завжди, якщо це гарна ідея чи ні, залежить від багатьох інших умов, тому єдиною відповіддю є експеримент із реальним використанням.


4
Я б сказав, що це хороший компроміс, щоб отримати якомога більше звернень кешу (оскільки зменшена структура може легше вміщуватися в кеш), не втрачаючи при цьому багато часу на випадковий доступ.
meneldal

Я думаю, що це можна вдосконалити. Я мав успіх у минулому з подібною, але різною проблемою, коли використання передбачення галузей дуже допомогло. Це може допомогти поділити if(code != 3) return code;наif(code == 0) return 0; if(code==1) return 1; if(code == 2) return 2;
куцкем

@kutschkem: у цьому випадку __builtin_expect& co чи PGO також можуть допомогти.
Маттео Італія

23

Це скоріше "довгий коментар", ніж конкретна відповідь

Якщо ваші дані не є чимось добре відомим, я сумніваюся, що хтось може ПРЯМО відповісти на ваше запитання (і я не знаю нічого, що відповідає вашому опису, але тоді я не знаю ВСІХ про всі види шаблонів даних для всіх види використання-випадки). Рідкі дані є поширеною проблемою у високопродуктивних обчисленнях, але це, як правило, "у нас дуже великий масив, але лише деякі значення не нульові".

Для не добре відомих моделей, таких як, на мою думку, ваша, ніхто не дізнається безпосередньо, що краще, і це залежить від деталей: наскільки випадковим є випадковий доступ - це система, що отримує доступ до кластерів елементів даних, чи це абсолютно випадково, як з рівномірний генератор випадкових чисел Дані таблиці повністю випадкові, чи є послідовності 0, а потім послідовності 1, з розсіюванням інших значень? Кодування запустити довжину буде добре, якщо у вас досить довгі послідовності 0 і 1, але не буде працювати, якщо у вас є "шахівниця 0/1". Крім того, вам доведеться вести таблицю "вихідних точок", щоб ви могли досить швидко пропрацювати свій шлях до відповідного місця.

Я давно знаю, що деякі великі бази даних - це просто велика таблиця оперативної пам’яті (дані цього абонента на телефонній станції в цьому прикладі), і одна з проблем полягає в тому, що кешування та оптимізація таблиць сторінок у процесорі досить марні. Абонент настільки рідко такий же, як той, хто нещодавно дзвонив комусь, що жодних попередньо завантажених даних немає, це просто чисто випадковий випадок. Великі таблиці сторінок - найкраща оптимізація для цього типу доступу.

У багатьох випадках компроміс між "швидкістю та малим розміром" є однією з тих речей, якими ви маєте вибирати в інженерії програмного забезпечення [в іншому інженерії це не обов'язково так багато компромісу]. Отже, "витрата пам'яті на більш простий код" досить часто є кращим вибором. У цьому сенсі "просте" рішення, швидше за все, краще за швидкість, але якщо ви маєте "краще" використання оперативної пам'яті, то оптимізація під розмір таблиці дасть вам достатню продуктивність і хороше поліпшення розміру. Існує багато різних способів, як ви могли цього досягти - як це запропоновано в коментарі, 2-бітове поле, де зберігаються два-три найпоширеніших значення, а потім якийсь альтернативний формат даних для інших значень - хеш-таблиця буде моїм перший підхід, але список або двійкове дерево також може працювати - знову ж, це залежить від моделей, де знаходяться ваші "не 0, 1 або 2". Знову ж таки, це залежить від того, як значення "розкидані" в таблиці - вони є в кластерах чи вони більш рівномірно розподілені?

Але проблема в тому, що ви все ще читаєте дані з ОЗУ. Потім ви витрачаєте більше коду на обробку даних, включаючи якийсь код, щоб впоратись із "це не є загальним значенням".

Проблема з найбільш поширеними алгоритмами стиснення полягає в тому, що вони засновані на розпаковувальних послідовностях, тому ви не можете отримати до них випадковий доступ. І накладні розбиття великих даних на шматки, скажімо, 256 записів одночасно, і розпакування 256 в масив uint8_t, отримання потрібних даних, а потім викидання нестиснених даних, навряд чи дасть вам добро продуктивність - якщо, мабуть, це має певне значення.

Зрештою, вам, мабуть, доведеться реалізувати одну чи декілька ідей у ​​коментарях / відповідях, щоб перевірити, чи допоможе це вирішити вашу проблему, чи шина пам'яті все ще є головним обмежуючим фактором.


Дякую! Врешті-решт, мене просто цікавить, що швидше, коли 100% процесора зайняті петлями над такими масивами (різні потоки на різних масивах). В даний час із uint8_tмасивом пропускна здатність оперативної пам’яті насичується після ~ 5 потоків, що працюють над цим одночасно (у системі чотирьохканальних каналів), тому використання більше 5 потоків більше не дає ніякої користі. Я хотів би, щоб це використовувало> 10 потоків, не наштовхуючись на проблеми з пропускною здатністю оперативної пам'яті, але якщо сторона ЦП доступу стає настільки повільною, що 10 потоків отримують менше, ніж 5 потоків раніше, це, очевидно, не буде прогресом.
JohnAl

@JohnAl Скільки у вас ядер? Якщо ви пов'язані з процесором, немає сенсу мати більше потоків, ніж ядра. Також, можливо, час поглянути на програмування GPU?
Мартін Боннер підтримує Моніку

@MartinBonner У мене зараз 12 тем. І я погоджуюся, це, мабуть, дуже добре працює на GPU.
JohnAl

2
@JohnAI: Якщо ви просто запускаєте кілька версій одного і того ж неефективного процесу в декількох потоках, ви завжди будете бачити обмежений прогрес. Перемоги в розробці алгоритму для паралельної обробки будуть більшими, ніж у налаштуваннях структури зберігання.
Джек Едлі

13

Що я робив у минулому - це використовувати хешмап перед бітсетом.

Це вдвічі зменшує простір порівняно з відповіддю Маттео, але може бути повільнішим, якщо "винятки" шукають повільно (тобто існує багато винятків).

Однак часто "кеш - це король".


2
Як саме хешмап вдвічі зменшить простір порівняно з відповіддю Маттео ? Що має бути в цій хешмапі?
JohnAl

1
@JohnAl Використання 1-бітного набору = bitvec замість 2-бітного bitvec.
o11c

2
@ o11c Я не впевнений, чи правильно це розумію. Ви маєте на увазі мати масив з 1 бітових значень, де 0засоби дивляться,main_arr а 1значить дивляться наsec_arr (у випадку з кодом Matteos)? Для цього було б потрібно загалом більше місця, ніж відповідь Matteos, оскільки його один додатковий масив. Я не зовсім розумію, як би ви це зробили лише використовуючи половину місця порівняно з відповіддю Маттеоса.
JohnAl

1
Чи можете ви це уточнити? Ви дивіться вгору expectional випадків перші , а потім подивитися в растровому зображенні? Якщо це так, я підозрюю, що повільний пошук у хеші перекриє економію при зменшенні розміру растрової карти.
Мартін Боннер підтримує Моніку

Я подумав, що це називається хешлінкінг - але Google не виявляє відповідних звернень, тому це має бути щось інше. Як зазвичай це працювало, це було сказати байтовий масив, який би містив значення, переважна більшість яких, скажімо, знаходилися між 0..254. Тоді ви б використовували 255 як прапор, і якби у вас був елемент 255, ви шукали справжнє значення в пов'язаній хеш-таблиці. Може хтось згадає, як його називали? (Я думаю, що я читав про це в старому IBM TR.) У будь-якому випадку, ви також можете впорядкувати його так, як пропонує @ o11c - завжди шукайте спочатку хеш, якщо його немає, загляньте у свій бітовий масив.
давидбак

11

Якщо ви не маєте шаблону для ваших даних, навряд чи є якась оптимізована швидкість або розмір, і - якщо припустити, що ви орієнтуєтесь на звичайний комп'ютер - 10 Мб все одно не така вже й велика справа.

У ваших питаннях є два припущення:

  1. Дані погано зберігаються, оскільки ви використовуєте не всі біти
  2. Зберігання його краще зробить все швидше.

Я думаю, що обидва ці припущення є помилковими. У більшості випадків відповідним способом зберігання даних є збереження найбільш природного зображення. У вашому випадку це той, на який ви пішли: байт для числа від 0 до 255. Будь-яке інше представлення буде складнішим, а отже - всі інші рівні речі - повільнішими та схильними до помилок. Щоб відмовитися від цього загального принципу, вам потрібна вагоміша причина, ніж потенційно шість "витрачених" бітів на 95% ваших даних.

Для вашого другого припущення це буде правдою, якщо і тільки якщо зміна розміру масиву призводить до значно менших помилок кешу. Чи відбудеться це, можна остаточно визначити лише шляхом профілювання робочого коду, але я думаю, що це суттєво не змінить. Оскільки ви довільно будете отримувати доступ до масиву в будь-якому випадку, процесор намагатиметься знати, які біти даних кешувати і зберігати в будь-якому випадку.


8

Якщо дані та звернення розподіляються рівномірно випадковим чином, продуктивність, ймовірно, залежатиме від того, яка частка доступу уникає пропуску кешу зовнішнього рівня. Оптимізація, що вимагає знати, який масив розмірів може бути надійно розміщений у кеші. Якщо ваш кеш-пам'ять достатньо великий, щоб вмістити один байт на кожні п’ять комірок, найпростішим підходом може бути те, щоб один байт утримував п'ять базових-трьох закодованих значень у діапазоні 0-2 (є 243 комбінації з 5 значень, так що буде помістити в байт) разом з масивом 10 000 000 байт, який запитувався б кожного разу, коли значення базового 3 вказує на "2".

Якщо кеш не такий великий, але міг би вмістити один байт на 8 комірок, тоді не вдасться використовувати одне байтне значення для вибору серед усіх 6,561 можливих комбінацій восьми базових-3 значень, але оскільки єдиний ефект зміна 0 або 1 на 2 означало б інакше непотрібний пошук, правильність не потребує підтримки всіх 6,561. Натомість можна було б зосередитись на 256 найбільш «корисних» значеннях.

Особливо, якщо 0 частіше, ніж 1, або навпаки, хорошим підходом може бути використання 217 значень для кодування комбінацій 0 і 1, які містять 5 або менше 1-х, 16 значень для кодування xxxx0000 через xxxx1111, 16 для кодування 0000xxxx через 1111xxxx, і один для xxxxxxxx. Чотири значення залишаться для будь-якого іншого використання. Якщо дані розподіляються випадковим чином, як описано, незначна більшість усіх запитів потрапляє до байтів, які містять лише нулі та одиниці (приблизно в 2/3 всіх восьми груп, усі біти будуть нулями та одиницями, і приблизно 7/8 вони мали б шість або менше 1 біт); переважна більшість тих, хто не приземлиться в байті, який містив чотири x, і мав би 50% шанс висадитися на нуль чи один. Таким чином, лише приблизно один з чотирьох запитів потребує пошуку великого масиву.

Якщо дані розподіляються випадковим чином, але кеш недостатньо великий, щоб обробляти один байт на вісім елементів, можна спробувати використати цей підхід для кожного байту, що обробляє більше восьми елементів, але якщо немає сильного ухилу до 0 або до 1 , частка значень, з якими можна обробитись без необхідності пошуку у великому масиві, зменшиться зі збільшенням числа оброблюваних байтів.


7

Я додам відповідь @ o11c , оскільки його формулювання може бути трохи заплутаним. Якщо мені потрібно видавити останній біт і цикл процесора, я б зробив наступне.

Почнемо зі створення збалансованого дерева двійкового пошуку, яке містить 5% випадків "щось інше". Для кожного пошуку ви швидко ходите по дереву: у вас є 10000000 елементів: 5% з яких знаходиться в дереві: отже, структура даних про дерево містить 500000 елементів. Якщо зробити це за O (log (n)) час, ви отримаєте 19 ітерацій. Я не є експертом у цьому, але, мабуть, є якісь ефективні для пам’яті реалізації. Давайте вгадаємо:

  • Збалансоване дерево, тому положення піддерева можна обчислити (індекси не потрібно зберігати у вузлах дерева). Таким же чином купа (структура даних) зберігається в лінійній пам'яті.
  • Значення 1 байта (від 2 до 255)
  • 3 байти для індексу (10000000 займає 23 біта, що відповідає 3 байтам)

Усього, 4 байти: 500000 * 4 = 1953 кБ. Підходить під кеш!

Для всіх інших випадків (0 або 1) ви можете використовувати бітвектор. Зауважте, що ви не можете залишити 5% інших випадків для випадкового доступу: 1,19 Мб.

Поєднання цих двох використовує приблизно 3099 МБ. Використовуючи цю техніку, ви заощадите фактор 3,08 пам'яті.

Однак це не перевершує відповідь @Matteo Italia (для якої використовується 2,76 МБ), шкода. Чи є щось, що ми можемо зробити додатково? Найбільш споживаюча частина пам'яті - це 3 байти індексу в дереві. Якщо ми можемо знизити це до 2, ми б заощадили 488 кБ, а загальне використання пам'яті склало б: 2,622 Мб, що менше!

Як ми це робимо? Треба зменшити індексацію до 2 байт. Знову ж таки, 10000000 займає 23 біти. Нам потрібно вміти скинути 7 біт. Ми можемо просто зробити це, розділивши діапазон 10000000 елементів на 2 ^ 7 (= 128) областей 78125 елементів. Тепер ми можемо створити збалансоване дерево для кожного з цих регіонів, в середньому 3906 елементів. Вибір правильного дерева виконується простим поділом цільового індексу на 2 ^ 7 (або бітовий зсув>> 7 ). Тепер необхідний індекс для зберігання може бути представлений рештою 16 бітами. Зауважте, що є деяка накладна довжина дерева, яку потрібно зберігати, але це мізерно. Також зауважте, що цей механізм розщеплення зменшує необхідну кількість ітерацій для прогулянки по дереву, тепер це зменшується на 7 ітерацій менше, оскільки ми скинули 7 біт: залишилось лише 12 ітерацій.

Зауважте, що ви можете теоретично повторити процес, щоб відрізати наступні 8 біт, але для цього знадобиться створити 2 ^ 15 збалансованих дерев, що мають в середньому ~ 305 елементів. Це призвело б до отримання 2.143 Мб, маючи лише 4 повторення ходити по дереву, що є значною швидкістю, порівняно з 19 ітераціями, з яких ми розпочали.

Як остаточний висновок: це перемагає 2-бітну векторну стратегію за допомогою невеликого використання пам'яті, але це ціла боротьба за її реалізацію. Але якщо це може змінити розміщення кеша чи ні, можливо, варто спробувати.


1
Доблесні зусилля!
Давидбак

1
Спробуйте так: Оскільки 4% випадків мають значення 2 ... створіть набір виняткових випадків (> 1). Створіть дерево дещо так, як описано для дійсно виняткових випадків (> 2). Якщо він присутній у наборі та дереві, тоді використовуйте значення у дереві; якщо він присутній у наборі, а не в дереві, тоді використовуйте значення 2, інакше (немає в наборі) пошуку у вашому bitvector. Дерево буде містити лише 100000 елементів (байтів). Набір містить 500000 елементів (але значення немає зовсім). Чи зменшує це розмір, виправдовуючи його збільшення вартості? (100% пошукових запитів виглядають у наборі; 5% пошукових
запитів

Ви завжди хочете використовувати масив, відсортований за CFBS, коли у вас є незмінне дерево, тому немає розподілу для вузлів, а лише даних.
o11c

5

Якщо ви виконуєте лише операції з читання, краще було б не присвоювати значення одному індексу, а інтервалу індексів.

Наприклад:

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

Це можна зробити за допомогою структури. Ви також можете визначити клас, подібний до цього, якщо вам подобається підхід до ОО.

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
}

Тепер вам просто необхідно перебрати список інтервалів і перевірити, чи не знаходиться ваш індекс в одному з них, що в середньому може бути значно меншим обсягом пам'яті, але коштує більше ресурсів процесора.

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;
}

Якщо ви замовляєте інтервали за зменшенням розміру, ви збільшуєте ймовірність того, що предмет, який ви шукаєте, знайдений рано, що ще більше зменшує середнє значення пам’яті та використання ресурсів процесора.

Ви також можете видалити всі інтервали з розміром 1. Покладіть відповідні значення на карту та перевірте їх лише у тому випадку, якщо шуканий предмет не був знайдений в інтервалах. Це також повинно трохи підвищити середню ефективність.


4
Цікава ідея (+1), але я дещо скептичний, що це виправдає накладні витрати, якщо не буде багато тривалих пробіг 0 і / або довгих пробігів 1. Насправді ви пропонуєте використовувати кодування даних за довжиною виконання. Це може бути добре в деяких ситуаціях, але, мабуть, не є загальним загальним підходом до цієї проблеми.
Джон Коулман

Правильно. Зокрема, для випадкового доступу це майже напевно повільніше, ніж простий масив або unt8_t, навіть якщо він займає набагато менше пам’яті.
близько

4

Давно давно я можу просто згадати ...

В університеті ми отримали завдання прискорити програму трасування променів, яка повинна читати за алгоритмом знову і знову з буферних масивів. Друг сказав мені, щоб я завжди використовував RAM-зчитування, що є кратними 4Bytes. Тож я змінив масив із шаблону [x1, y1, z1, x2, y2, z2, ..., xn, yn, zn] на шаблон [x1, y1, z1,0, x2, y2, z2 , 0, ..., xn, yn, zn, 0]. Значить я додаю порожнє поле після кожної тривимірної координати. Після деякого тестування працездатності: це було швидше. Отож, довга історія: Читайте кілька байтів зі свого масиву з оперативної пам’яті, а може бути і з правильної вихідної позиції, тому ви читаєте невеликий кластер, де знаходиться в ньому індекс пошуку, і читаєте шуканий індекс з цього маленького кластера на процесорі. (У вашому випадку вам не потрібно буде вставляти поля заповнення, але концепція повинна бути зрозумілою)

Можливо, також інші множини можуть стати ключовими в нових системах.

Я не знаю, чи це спрацює у вашому випадку, тому якщо воно не працює: Вибачте. Якщо це спрацює, я був би радий почути про деякі результати тестів.

PS: О, і якщо є схема доступу або індекси, що доступні поблизу, ви можете повторно використовувати кешований кластер.

PPS: Можливо, що множинний фактор був більше схожий на 16Bytes або щось подібне, я вже дуже давно пам’ятаю.


Ви, напевно, думаєте про кеш-лінії, які зазвичай складають 32 або 64 байти, але це не дуже допоможе тут, оскільки доступ випадковий.
Сурт

3

Дивлячись на це, ви можете розділити свої дані, наприклад:

  • біт, який індексується і представляє значення 0 (std :: вектор буде корисний тут)
  • біт, який індексується і представляє значення 1
  • std :: вектор для значень 2, що містить індекси, які посилаються на це значення
  • карта для інших значень (або std :: vector>)

У цьому випадку всі значення з'являються до заданого індексу, так що ви навіть можете видалити один з бітів і представляє значення, як воно відсутнє в інших.

Це допоможе вам заощадити деяку пам’ять для цього випадку, хоча й гірший випаде. Також вам знадобиться більше енергії процесора, щоб зробити пошук.

Обов’язково міряйте!


1
Біт для одиниць / нулів. Набір індексів на двійки. І рідкий асоціативний масив для решти.
Red.Wave

Ось короткий підсумок
JVApen

Нехай ОП знає умови, щоб він міг шукати альтернативні варіанти реалізації кожного.
Red.Wave

2

Як згадує Матс у своїй коментарі-відповіді, важко сказати, що насправді є найкращим рішенням, не знаючи конкретно, які дані у вас є (наприклад, чи є довгі пробіги 0 і так далі), і як виглядає ваша схема доступу на кшталт (чи означає "випадковий" значення "всюди" або просто "не строго абсолютно лінійно" або "кожне значення точно один раз, просто рандомізоване" або ...).

З цього приводу, на увазі є два механізми:

  • Бітові масиви; тобто, якби у вас було лише два значення, ви могли б тривільно стиснути масив коефіцієнтом 8; якщо у вас є 4 значення (або "3 значення + все інше"), ви можете стиснути їх в два рази. Що може просто не вартувати клопотів і потребуватиме орієнтирів, особливо якщо у вас дійсно випадкові шаблони доступу, які уникають кешів, а отже, зовсім не змінюють час доступу.
  • (index,value)або (value,index)таблиці. Тобто, є одна дуже маленька таблиця для випадку 1%, можливо, одна таблиця для 5% випадку (для якої потрібно зберігати лише індекси, оскільки всі мають однакове значення), і великий стислий бітовий масив для останніх двох випадків. І під "таблицею" я маю на увазі те, що дозволяє відносно швидкий пошук; тобто, може бути хеш, бінарне дерево тощо, залежно від того, що у вас є, і ваших реальних потреб. Якщо ці підпрограми вмістяться у кеші 1-го та 2-го рівня, можливо, вам пощастить.

1

Я не дуже знайомий з C, але в C ++ ви можете використовувати неподписаний знак для представлення цілого числа в діапазоні 0 - 255.

Порівняно з нормальним int (я знову приїжджаю з Java та C ++ світу), у якому потрібно 4 байти (32 біта), для безпідписаного знаку потрібен 1 байт (8 біт). тож це може зменшити загальний розмір масиву на 75%.


Можливо, це вже є випадком використання uint8_t - 8 означає 8 біт.
Пітер Мортенсен

-4

Ви коротко описали всі характеристики розподілу вашого масиву; кинути масив .

Ви можете легко замінити масив рандомізованим методом, який виробляє той самий ймовірнісний вихід, як і масив.

Якщо послідовність має значення (створюючи одне і те ж значення для одного і того ж випадкового індексу), розгляньте можливість використання фільтра розквітання та / або хеш-карти для відстеження повторних звернень. Якщо ваш доступ до масиву дійсно випадковий, це зовсім непотрібно.


18
Я підозрюю, що тут використовується "випадковий доступ", щоб вказати, що доступ непередбачуваний, а не те, що вони насправді випадкові. (тобто призначений у значенні "файли з випадковим доступом")
Майкл Кей

Так, це, ймовірно. Однак, ОП не зрозуміло. Якщо доступ до ОП в будь-якому випадку не є випадковим, тоді вказується деяка форма розрідженого масиву, як і в інших відповідях.
Дютомхас

1
Я думаю, що у вас є точка, оскільки ОП вказала, що він буде перебирати весь масив у випадковому порядку. У випадку, коли потрібно дотримуватися лише розподілів, це хороша відповідь.
Інго Шальк-Шупп
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.