Pull to refresh

Оптимизация поиска при выборе кампании

Reading time4 min
Views5.2K

Бла-бла-бла


Когда от основной работы начинает пухнуть голова, я люблю переключить контекст и придумать себе задачку, связанную с улучшение работы какого-нибудь алгоритма, оптимизацией. Так как больше 10 лет я занимаюсь разработкой систем управления рекламой, все мои задачки так же связаны с этой сферой.


В последнее время мои мысли заняты вопросом как увеличить скорость поиска рекламных кампаний по заданным параметрам. Рекламная кампания — это, фактически, набор ограничений, которые надо проверить, чтобы выбрать подходящую под запрос. Пытаясь ускорить полный перебор я дошёл до интринсиков, но достижения нельзя было назвать выдающимися. При этом постоянно было чувство, что я иду не совсем верными путём, что где-то что-то надо перевернуть вверх ногами и скорость увеличится на порядок.


Вчера меня озарило после разговора с Пашей Бейзманом, выступавшим на днях с докладом на HighLoad++. Битовые операции!!! Да, я прекрасно понимаю мощь битовых операций, но я их неправильно готовил ;) Результаты тестов превзошли мои самые оптимистичные ожидания.


Идея


Один из типов ограничений при выборе кампании — это проверка по вхождению значения в набор. Например, показ кампании может быть ограничен по стране, области, городу, категории и т.п. Первое, что приходит в голову — это дерево со всеми значениями на уровне кампании. Второе — массив значений, если значений в наборе немного (около 10), прямой перебор будет быстре, чем использование деревьев. Для выбора кампании необходимо перебрать все и в каждой из них проверить вхождение заданного значения в набор.


Алгоритм с правильно приготовленными битовыми операциями выглядит следующим образом. Для заданного значения ограничения строим большую битовую маску, размер которой равен количеству кампаний и устанавливаем биты для кампаний, которые старгетированы на данное значение (или нет ограничений заданного типа). Битовые маски помещаем в дерево, где ключом служит значение ограничения. Например, для стран это будет дерево, где ключом является идентификатор страны, а значение — маска с битами, установленными для кампаний, которые можно показывать на эту страну.


С такой структурой очень просто работать. Зная, для какой страны необходимо выбрать кампании, мы выбираем по идентификатору битовую маску и производим операцию AND c масками других ограничений. Очень быстро и красиво.


Тесты


Куда же без тестов производительности… Я тестировал обработку одного ограничения для 1 миллиона кампаний. Для каждой из кампаний было сгенерировано случайным образом ограничение с набором в 10 значений от 0 до 256 (проверки на ошибки выделения памяти и т.п. из кода убраны):


    int nels = 10;
    int campaigns = 1000000;
    int *values = (int *) malloc(sizeof(int) * campaigns * nels);
    srand(13);
    for (int i = 0; i < campaigns; i++) {
        for (int j = 0; j < nels; j++) {
            values[i * nels + j] = rand() % 256;
        }
    }

Перебор со значениями в массиве


Пробуем просто перебрать все кампании со всеми ограничениями. Здесь и далее компилируем с опцией -O3.


Тестируем.


    clock_t cs, ce;
    int found_campaigns = 0;
    int reference = 13;
    cs = clock();
    for (int i = 0; i < campaigns; i++) {
        for (int j = 0; j < nels; j++) {
            if (values[i * nels + j] == reference) {
                found_campaigns++;
                break;
            }
        }
    }
    ce = clock();
    printf("test #1 bruteforce, found: %d, clocks: %ld, sec: %f\n", found_campaigns, (long) (ce - cs), (double) (ce - cs) / CLOCKS_PER_SEC);

Результат: clocks: 7034, sec: 0.007034.


Перебор со значениями в дереве


Пробуем вместо массива значений ограничения использовать дерево (Judy Arrays).


Подготавливаем данные
Pvoid_t *sets = (Pvoid_t) malloc(sizeof(Pvoid_t) * campaigns);
memset(sets, 0, sizeof(Pvoid_t) * campaigns);
for (int i = 0; i < campaigns; i++) {
    for (int j = 0; j < nels; j++) {
        PWord_t pw;
        JLI(pw, sets[i], values[i * nels + j]);
    }
}

Тестируем.


    found_campaigns = 0;
    cs = clock();
    for (int i = 0; i < campaigns; i++) {
        PWord_t pw;
        JLG(pw, sets[i], reference);
        if (pw) {
            found_campaigns++;
        }
    }
    ce = clock();
    printf("test #2 set, found: %d, clocks: %ld, sec: %f\n", found_campaigns, (long) (ce - cs), (double) (ce - cs) / CLOCKS_PER_SEC);

Результат: clocks: 7930, sec: 0.007930.


Перебор 10 значений массива оказался быстрее, чем использование дерева. Тут нет ни чего удивительного, перебор по массиву замечательно утилизирует кеш процессора. Примерное равенство между массивами и деревом было на 12 элементах.


Используем битовые маски


Подготавливаем битовые маски
    #define SET_BIT(_n, _i) _n[_i / 64] |= 1ULL << (_i % 64)
    #define CHECK_BIT(_n, _i) _n[_i / 64] & (1ULL << (_i % 64))

    PWord_t pw;
    Pvoid_t pv = (Pvoid_t) 0;
    int mask_size = sizeof(uint64_t) * (campaigns / 64 + 1);
    for (int i = 0; i < campaigns; i++) {
        for (int j = 0; j < nels; j++) {
            int id = values[i * nels + j];
            JLI(pw, pv, id);
            if (*pw) {
                uint64_t *mask = (uint64_t *) *pw;
                SET_BIT(mask, i);
            } else {
                uint64_t *mask = (uint64_t *) malloc(mask_size);
                memset(mask, 0, mask_size);
                SET_BIT(mask, i);
                *pw = (Word_t) mask;
            }
        }
    }

Тестируем.


    found_campaigns = 0;
    cs = clock();
    JLG(pw, pv, reference);
    if (pw) {
        uint64_t *mask = (uint64_t *) *pw;
        for (int i = 0; i < campaigns; i++) {
            if (CHECK_BIT(mask, i)) {
                found_campaigns++;
            }
        }
    }
    ce = clock();
    printf("test #3 bitmaps, found: %d, clocks: %ld, sec: %f\n", found_campaigns, (long) (ce - cs), (double) (ce - cs) / CLOCKS_PER_SEC);

Результат: clocks: 1393, sec: 0.001393.


В 5 раз быстрее чем перебор. Неплохо, но можно лучше.


Ускоряем использование битовых масок


Для ускорения будет использовать встроенную функцию gcc __builtin_ffsll, которая возвращает индекс первого установленого бита.


Тестируем.


    found_campaigns = 0;
    cs = clock();
    JLG(pw, pv, reference);
    if (pw) {
        uint64_t *mask = (uint64_t *) *pw;
        for (int i = 0; i < (campaigns / 64 + 1); i++) {
            uint64_t m = mask[i];
            while (m) {
                int n = __builtin_ffsll(m);
                m &= ~(1ULL << (n - 1));
                found_campaigns++;
            }
        }
    }
    ce = clock();
    printf("test #4 bitmaps 2, found: %d, clocks: %ld, sec: %f\n", found_campaigns, (long) (ce - cs), (double) (ce - cs) / CLOCKS_PER_SEC);

Результат: clocks: 28, sec: 0.000028.


В этом месте мои глаза полезли на лоб. Ускорение в 250 раз по сравнению с полным перебором.


Как работает процессор, какие есть SSE, AVX и тому подобные инструкции, как устроен кеш — эти знания позволяют ускорить работу программы, часто существенно. Но красивый алгоритм может ускорить работу на порядки.


Текст программы можно посмотреть здесь: https://github.com/saterenko/labs/tree/master/bit_limits


P.S.


Спасибо PkXwmpgN за найденную ошибку в инициализации, за-за неё заполнялся не весь массив тестовых данных, а только его десятая часть. После исправления ошибки время выполнения последнего теста увеличилось на порядок, т.е. ускорение по сравнению с полным перебором — 25 раз, глаза на лоб больше не лезут, но тоже впечатляет ;)

Tags:
Hubs:
Total votes 9: ↑9 and ↓0+9
Comments11

Articles