Фильтр Блума представляет собой структуру данных, которая может эффективно определить является ли элемент возможным элементом набора или определенно не относится к нему. Эта статья продемонстрирует простую реализацию фильтра Блума в C++.



Интерфейс


Давайте сначала определим интерфейс данного фильтра. Можно выделить три основные функции:

  • Конструктор
  • Функция, чтобы добавить элемент к фильтру Блума
  • Функция для запроса относится ли элемент частью фильтра Блума

Несколько задействованных переменных, включая небольшое количество векторов, также содержат состояние фильтра.

#include <vector>

struct BloomFilter {
  BloomFilter(uint64_t size, uint8_t numHashes);

  void add(const uint8_t *data, std::size_t len);
  bool possiblyContains(const uint8_t *data, std::size_t len) const;

private:
  uint64_t m_size;
  uint8_t m_numHashes;
  std::vector<bool> m_bits;
};

Стоит отметить, что std::vector<bool.> является намного более эффективной специализацией std::vector, требует только один бит на элемент (в отличие от одного байта в типичных реализациях).

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

template< class Key, class Hash = std::hash<Key> >
struct BloomFilter {
  ...
};

Это позволит фильтру Блума быть обобщенным для более сложных типов данных.

Параметры фильтра Блума


Есть два параметра для построения фильтра Блума:

  • Размер фильтра в битах
  • Число хэш-функций для использования

Мы можем вычислить ложный положительный коэффициент ошибок — n, на основе размера фильтра — m, числа хэш-функций — K и число вложенных элементов -N, с помощью формулы:



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



Реализация


Вы можете задаться вопросом, как же реализовать kk хеш-функции; двойное хеширование может использоваться, чтобы генерировать kk значения хэш-функции, не влияя на вероятность ложно-положительного результата! Такой результат можно получить используя формулу, где i — ординал, м — размер фильтра Блума и x — значение, которое будет хешировано:



Для начала нужно написать конструктор. Он просто записывает параметры масштабирования и битовый массив.

#include "BloomFilter.h"
#include "MurmurHash3.h"

BloomFilter::BloomFilter(uint64_t size, uint8_t numHashes)
      : m_size(size),
        m_numHashes(numHashes) {
  m_bits.resize(size);
}

Далее давайте напишем функцию для вычисления 128 — битного хэша данного элемента. В данной реализации используется MurmurHash3, 128 бит хэш — функция, которая имеет хорошие компромиссы между производительностью, распределение, потоковым поведением и сопротивлением противоречиям. Поскольку эта функция генерирует 128 бит хэш и нам нужно 2х64 битные хэши, мы можем разделить возвращенный хэш пополам, чтобы получить хэшa(x) хэшb(x).

std::array<uint64_t, 2> hash(const uint8_t *data,
                             std::size_t len) {
  std::array<uint64_t, 2> hashValue;
  MurmurHash3_x64_128(data, len, 0, hashValue.data());

  return hashValue;
}

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

inline uint64_t nthHash(uint8_t n,
                        uint64_t hashA,
                        uint64_t hashB,
                        uint64_t filterSize) {
    return (hashA + n * hashB) % filterSize;
}

Все, что осталось сделать — это написать функции для набора контрольных битов для заданных элементов.

void BloomFilter::add(const uint8_t *data, std::size_t len) {
  auto hashValues = hash(data, len);

  for (int n = 0; n < m_numHashes; n++) {
      m_bits[nthHash(n, hashValues[0], hashValues[1], m_size)] = true;
  }
}

bool BloomFilter::possiblyContains(const uint8_t *data, std::size_t len) const {
  auto hashValues = hash(data, len);

  for (int n = 0; n < m_numHashes; n++) {
      if (!m_bits[nthHash(n, hashValues[0], hashValues[1], m_size)]) {
          return false;
      }
  }

  return true;
}

Результаты


С помощью фильтра Блума 4.3MБ и 13 хэш-функции, вставляя элементы 1.8МБ приняли примерно 189 наносекунд для каждого элемента на средней производительности ноутбуке.

Оригинал данного поста Вы можете найти по ссылке.

Комментарии (30)


  1. RomanArzumanyan
    13.04.2016 15:19
    +3

    Можно кратное введение для тех, кто не в теме: где применяется данный фильтр, и почему его стоит писать на С++?


    1. IvaYan
      13.04.2016 15:32
      +6

      Фильтр Блума позволяет очень эффективно определить, «видел» ли фильтр некие данные. Фильтр может давать ложноположительные срабатывания (не видел, но говорит, что видел), но никогда на дает ложноотрицательных (если фильтр говорит, что не видел, значит 100% не видел).

      Используется, например, в базах данных при поиске страниц. Через фильтр прогоняется какой-нибудь идентификатор страницы, и если фильтр говорит, что «видел» его, то страница в памяти, иначе — на диске. Фильтр он в том смысле, что «фильтрует» запросы к диску.


      1. Komei
        15.04.2016 19:19

        Меня мучает такой вопрос, а чем он эффективнее простого битового массива + установка бита по одной хеш-функции?


        1. khim
          15.04.2016 19:36
          +1

          Меньше ложноположительных срабатываний.

          Грубо: пусть у вас массив, скажем, на 20'000 битов. Если он «увидит» 1000 элементов с одной хорошей хеш-функцией, то он пропустит новый элемент с вероятностью 1/20.

          Если же вы построили блум-фильтр на 10 функций, то даже если в этом массиве будет установлена половина битов (а больше ну никак не выйдет), то каждая функция будет давать ложноположительное срабатывание с вероятностью 1/2 — но зато все вместе они датут отсеивание с 99.9% вероятностью! А 1/1000 — это гораздо лучше, чем 1/20, согласитесь.

          Чем больше у вас функций — тем больше выигрыш, но и тем больше памяти нужно для эффективной работы и тем медленнее работает фильтр.


          1. Komei
            15.04.2016 22:04

            Большое спасибо. Теперь ясно.


    1. afend69
      13.04.2016 15:38
      +2

      Очень просто. У Вас есть миллиард объектов. Вам нужно найти объект, удовлетворяющий заданным условиям (состоянию). Прежде чем начать поиск, при помощи фильтра Блума Вы можете определить (с определенной вероятностью), что этот объект есть в этом наборе. Или его там нет. Читал, что фильтр Блума использует Google.


      1. RPG18
        13.04.2016 16:15
        +1

        В частности в LevelDB


    1. Whiteha
      13.04.2016 16:40
      +2

      Фильтр Блума компактно хранит множество элементов и позволяет проверять принадлежность заданного элемента к множеству, с фолспозитивами, но без фолснегативов. C++ потому что это структура данных предназначена для чего-то высокопроизводительного, относительно больших объемов данных, а обычно такие вещи требуют плюсов.
      Примеры из вики:
      Прокси-сервер Squid использует фильтры Блума для опции cache digests.
      Google BigTable использует фильтры Блума для уменьшения числа обращений к жесткому диску при проверке на существование заданной строки или столбца в таблице базы данных.
      Компьютерные программы для проверки орфографии.
      Биоинформатика.


    1. JIghtuse
      13.04.2016 18:48

      В оригинальной статье есть краткая справка и пара ссылок на подробности. Непонятно, зачем при переводе убрали.


  1. bsisoft
    13.04.2016 15:38
    +1

    Скажите, а Вам приходилось на практике применять фильтр Блума? Если несложно, поясните где и как Вы его использовали?


    1. Biga
      13.04.2016 18:24

      Могу поделиться своим примером.
      Есть сервер сообщений для игры. Обычный pub-sub, то есть клиент коннектится к серверу, подписывается на каналы и ждёт сообщений. Запущено несколько экземпляров сервера для балансировки. Сервера соединяются друг с другом и сообщают, у кого какие подписки, чтобы не бродкастить все сообщения на все сервера. Тут и применяется блум-фильтр: сами идентификаторы подписок не хранятся, есть только несколько бит в блум-фильтре. Используется модификация блум-фильтра — со счётчиком, но работает абсолютно так же.


    1. khim
      13.04.2016 18:40
      +1

      А кеширование — вы когда-либо использовали? Вот Блум Фильтр — это, грубо говоря, «кеш для кешей»: так как вы храните на один элемент данных только пару-тройку бит, то вы можете точно сказать — стоит вам ходить в кеш или нужно напрямую идти в «медленное» хранилище. Идея кажется дикой — но только до тех пор пока кеш у вас один. Если же кеширующих серверов становится много — вот тогда важно знать какой из них чего знает (и знает ли вообще хоть кто-нибудь хоть чего-нибудь).


  1. begemot_sun
    13.04.2016 16:13

    Добавьте тэг: фильтр Блума. Чтобы было доступно тут:
    https://habrahabr.ru/search/?q=%5B%D1%84%D0%B8%D0%BB%D1%8C%D1%82%D1%80%20%D0%B1%D0%BB%D1%83%D0%BC%D0%B0%5D&target_type=posts


  1. Jek_Rock
    13.04.2016 16:23
    +1

    Вот что Мейерс пишет про std::vector:
    «Vector как контейнер STL обладает лишь двумя недостатками. Во-первых, это вообще не контейнер STL. Во-вторых, он не содержит bool.»


    1. laphroaig
      13.04.2016 16:37

      Это «Совет 18. Избегайте vector»


      1. laphroaig
        13.04.2016 16:43

        ух как сложно vector<bool> сюда запостить, даже хабр его не любит


  1. SourenTopchian
    13.04.2016 16:23
    -1

    Можно это считать алгоритмом кластеризации (в некотором смысле)?


  1. ryzhikovas
    13.04.2016 16:23

    «Стоит отметить, что std::vector является намного более эффективной специализацией» — очень спорное утверждение. Требует меньше памяти — да. Но скорость доступа меньше, чем, например, у vector за счет объекта-провайдера, возвращаемого operator[].


    1. khim
      13.04.2016 17:00

      Объект провайдер современные реализации легко выкидывают, это не беда. Беда в том, что на платформе где это важнее всего (почти весь TOP500 — это x86, ведь так?) он не использует операции, которые могли бы его сделать реально быстрым (компилятор про них не знает, а реализацию на asm'е некому сделать).

      Кстати неплохая идея для того, кто хочет лучше разобраться с C++: потратить пару вечеров и сделать приличную реализацию vector<bool>… а потом потратить ещё пару месяцев, чтобы убедить разработчиков GCC его включить к себе в библиотеку :-)

      Сделать парочку бенчмарков, померить скорость… есть желающие?


      1. encyclopedist
        15.04.2016 03:33

        Уже делали.

        Вот например статья Ховарда Хиннанта (автора libc++).

        Где-то была и оптимизированная реализация, кажется ввиде патча к одной из стандартных библиотек, кажется как раз libc++, но я что-то не могу найти.


        1. khim
          15.04.2016 12:01

          Оптимизированную версию делали и не раз. Я сам лично делал и использовал отпимизированную версию в паре своих проктов — это как раз не фокус.

          Фокус в том, чтобы сделать оптимизированную версию совместимую с std::vector<bool> и продвинуть её в GCC/Clang (ну или, если вы мазохист, в MSVC).

          Дело в том, что в STL — весьма много разновсяких требований, а в x86 (да и в других процессорах) — много разных интересных функций (к примеру тот же find: использовать прямо вот так __builtin_ctz там нельзя, так как результат не определён, но если мы пишем под GCC/Clang, то мы можем вспомнить что у нас есть ассемблер, а в нём — реакция на нуль вполне разумна: BSF/BSR флаг Z выставляют правильно), так что есть где порезвиться и поразвлекаться при создании всего этого (опять-таки при наличии SSE4.2/AVX2 есть PTEST — но для него нужно увеличивать минимальные размеры std::vector<bool>).

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


          1. JIghtuse
            15.04.2016 15:01

            std::vector<bool> ведь упразднять хотят, и вообще он немало проблем родил. Смысл его куда-то продвигать?


            1. khim
              15.04.2016 15:31

              У вас с временами что-то не то: не хотят, а хотели. И от этой идеи отказались. И в C++11 и в C++14 он вполне себе есть и никто от него отказываться не собирается. Так может стоит наконец сделать приличную реализацию?


  1. theg4sh
    14.04.2016 12:43

    > Стоит отметить, что std::vector является намного более эффективной специализацией std::vector,…

    Не уловил разницы между std::vector и std::vector, видимо упущено что-то типа?


    1. theg4sh
      14.04.2016 12:48

      … что-то типа <bool>?


      1. theg4sh
        14.04.2016 12:54
        +1

        Благодарю, вопрос снят.


        1. Jek_Rock
          14.04.2016 14:18
          +1

          Как уже писали выше, Хабр тоже не любит std::vector.


  1. knagaev
    15.04.2016 12:34
    +2

    Товарищи… ну нельзя же так переводить…
    У вас
    «С помощью фильтра Блума 4.3MБ и 13 хэш-функции, вставляя элементы 1.8МБ приняли примерно 189 наносекунд для каждого элемента на средней производительности ноутбуке.»
    Должно быть
    «C 4.3-Мбайтным фильтром Блума и 13 хэш-функциями при добавлении 1.8 миллионов элементов среднее время вставки одного элемента составило приблизительно 189 наносекунд на моем ноутбуке.»
    (Оригинал — With a 4.3MB Bloom filter and 13 hash functions, inserting 1.8M items took roughly 189 nanoseconds per element on my laptop.)

    P.S. И всё равно за статью большое спасибо :)


    1. lockywolf
      20.04.2016 15:11

      Как-то не очень круто иметь фильтр в три раза больше самого набора данных.


      1. knagaev
        20.04.2016 15:15
        +2

        Тут в том-то и дело, что в оригинальной статье фильтр мерился в мегабайтах, а набор данных в элементах.
        Если элементы размером не меньше трёх битов, то набор всё-таки будет больше фильтра :)