Вступительное слово


Я выступил с этим докладом на английском языке на конференции GopherCon Russia 2019 в Москве и на русском — на митапе в Нижнем Новгороде. Речь в нём идёт о bitmap-индексе — менее распространённом, чем B-tree, но не менее интересном. Делюсь записью выступления на конференции на английском и текстовой расшифровкой на русском.

Мы рассмотрим, как устроен bitmap-индекс, когда он лучше, когда — хуже других индексов и в каких случаях он значительно быстрее них; увидим, в каких популярных СУБД уже есть bitmap-индексы; попробуем написать свой на Go. А «на десерт» мы воспользуемся готовыми библиотеками, чтобы создать свою супербыструю специализированную базу данных.

Очень надеюсь, что мои труды окажутся для вас полезными и интересными. Поехали!

Введение



http://bit.ly/bitmapindexes
https://github.com/mkevac/gopherconrussia2019

Привет всем! Сейчас шесть вечера, мы все суперуставшие. Замечательное время, чтобы поговорить о скучной теории индексов баз данных, да? Не волнуйтесь, у меня будет пара строчек исходного кода тут и там. :-)

Если без шуток, то доклад переполнен информацией, а у нас не так много времени. Поэтому давайте начинать.

Сегодня я буду говорить о следующем:

  • что такое индексы;
  • что такое bitmap-индекс;
  • где он используется и где он НЕ используется и почему;
  • простая имплементация на Go и немного борьбы с компилятором;
  • чуть менее простая, но гораздо более производительная имплементацию на Go-ассемблере;
  • «проблемы» bitmap-индексов;
  • существующие реализации.


Так что такое индексы?




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

Это огромная сложная тема, переполненная тонкостями и компромиссами, но, посмотрев на десятки лет разработки и исследования различных баз данных, я готов утверждать, что существует всего несколько широко используемых подходов к созданию индексов БД.



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

Обычно мы делаем это, используя разного рода деревья. Примером может служить большая коробка с материалами в вашем шкафу, в которой находятся более мелкие коробки с материалами, разделёнными по различным тематикам. Если вам нужны материалы, то вы наверняка будете искать их в коробке с надписью «Материалы», а не в той, на которой написано «Печеньки», так ведь?



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



Третий подход — избавиться от необходимости поиска. Это мы делаем с помощью Bloom-фильтров или cuckoo-фильтров. Первые дают ответ мгновенно, избавляя вас от необходимости осуществлять поиск.



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

Как я уже сказал, тема индексов БД обширна и переполнена компромиссами. Это значит, что иногда мы можем использовать несколько подходов одновременно: если нам нужно ещё сильнее ускорить поиск или если необходимо покрыть все возможные типы поиска.

Сегодня я расскажу о наименее известном подходе из названных — о bitmap-индексах.

Кто я такой, чтобы говорить на эту тему?




Я работаю тимлидом в Badoo (возможно, вы лучше знаете другой наш продукт — Bumble). У нас уже более 400 млн пользователей по всему миру и много фич, которые занимаются тем, что подбирают для них лучшую пару. Делаем мы это с помощью кастомных сервисов, использующих в том числе и bitmap-индексы.

Так что же такое bitmap-индекс?



Bitmap-индексы, как подсказывает нам название, используют битмапы или битсеты для того, чтобы заимплементить поисковый индекс. С высоты птичьего полета этот индекс состоит из одного или нескольких таких битмапов, представляющих какие-либо сущности (типа людей) и их свойства или параметры (возраст, цвет глаз и т. д.), и из алгоритма, использующего битовые операции (AND, OR, NOT) для ответа на поисковый запрос.

Нам говорят что bitmap-индексы лучше всего подходят и очень производительны для случаев, когда есть поиск, объединяющий запросы по многим столбцам, имеющим малую кардинальность (представьте «цвет глаз» или «семейное положение» против чего-то типа «расстояние от центра города»). Но позже я покажу, что они прекрасно работают и в случае со столбцами с высокой кардинальностью.

Рассмотрим простейший пример bitmap-индекса.

Представьте, что у нас есть список московских ресторанов с бинарными свойствами вроде этих:

  • рядом с метро (near metro);
  • есть частная парковка (has private parking);
  • есть веранда (has terrace);
  • можно забронировать столик (accepts reservations);
  • подходит для вегетарианцев (vegan friendly);
  • дорогой (expensive).


Давайте дадим каждому ресторану порядковый номер начиная с 0 и выделим память под 6 битмапов (один для каждой характеристики). Затем мы заполним эти битмапы в зависимости от того, обладает ресторан данным свойством или нет. Если у ресторана 4 есть веранда, то бит №4 в битмапе «есть веранда» будет выставлен в 1 (если веранды нет, то в 0).

Теперь у нас есть самый простой bitmap-индекс из возможных, и мы можем использовать его для ответа на запросы вроде:

  • «Покажи мне рестораны, подходящие для вегетарианцев»;
  • «Покажи мне недорогие рестораны с верандой, где можно забронировать столик».




Как? Давайте посмотрим. Первый запрос очень простой. Всё, что нам нужно, — это взять битмап «подходит для вегетарианцев» и превратить его в список ресторанов, чьи биты выставлены.


Второй запрос немного сложнее. Нам необходимо использовать битовую операцию NOT на битмапе «дорогой», чтобы получить список недорогих ресторанов, затем за-AND-ить его с битмапом «можно забронировать столик» и за-AND-ить результат с битмапом «есть веранда». Результирующий битмап будет содержать список заведений, которые соответствуют всем нашим критериям. В данном примере это только ресторан «Юность».


Тут очень много теории, но не волнуйтесь, мы увидим код очень скоро.

Где используются bitmap-индексы?



Если вы «загуглите» bitmap-индексы, 90% ответов будут так или иначе связаны с Oracle DB. Но остальные СУБД тоже наверняка поддерживают такую крутую штуку, правда? Не совсем.

Пройдёмся по списку основных подозреваемых.

MySQL ещё не поддерживает bitmap-индексы, но есть Proposal с предложением добавить эту опцию (https://dev.mysql.com/worklog/task/?id=1524).

PostgreSQL не поддерживает bitmap-индексы, но использует простые битмапы и битовые операции для объединения результатов поиска по нескольким другим индексам.

У Tarantool есть bitset-индексы, он поддерживает простой поиск по ним.

У Redis есть простые битовые поля (https://redis.io/commands/bitfield) без возможности поиска по ним.

MongoDB ещё не поддерживает bitmap-индексы, но также есть Proposal с предложением добавить эту опцию https://jira.mongodb.org/browse/SERVER-1723

Elasticsearch использует битмапы внутри (https://www.elastic.co/blog/frame-of-reference-and-roaring-bitmaps).




  • Но в нашем доме появился новый сосед: Pilosa. Это новая нереляционная база данных, написанная на Go. Она содержит только bitmap-индексы и базирует всё на них. Мы поговорим о ней чуть позже.


Имплементация на Go


Но почему bitmap-индексы так редко используются? Прежде чем ответить на этот вопрос, я хотел бы продемонстрировать вам имплементацию очень простого bitmap-индекса на Go.

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

У нас есть один битмап на одну ресторанную характеристику, и каждый бит в битмапе говорит о том, есть у конкретного ресторана данное свойство или нет.

Нам понадобятся две вспомогательные функции. Одна будет использоваться для заполнения наших битмапов рандомными данными. Рандомными, но с определённой вероятностью того, что ресторан обладает каждым свойством. Например, я считаю, что в Москве очень мало ресторанов, в которых нельзя забронировать столик, и мне кажется, что примерно 20% заведений подходят для вегетарианцев.

Вторая функция будет преобразовывать битмап в список ресторанов.


Чтобы ответить на запрос «Покажи мне недорогие рестораны, у которых есть веранда и в которых можно забронировать столик», нам понадобятся две битовые операции: NOT и AND.

Мы можем немного упростить наш код, воспользовавшись более сложной операцией AND NOT.

У нас есть функции для каждой из этих операций. Обе они идут по слайсам, берут соответствующие элементы из каждого, объединяют их битовой операцией и кладут результат в результирующий слайс.

И вот теперь мы можем воспользоваться нашими битмапами и функциями для ответа на поисковый запрос.

Производительность не такая высокая, даже несмотря на то, что функции очень простые и мы прилично сэкономили на том, что не возвращали новый результирующий слайс при каждом вызове функции.

Попрофилировав немного с pprof, я заметил, что компилятор Go пропустил одну очень простую, но очень важную оптимизацию: function inlining.

Дело в том, что компилятор Go ужасно боится циклов, которые идут по слайсам, и категорически отказывается инлайнить функции, которые содержат такие циклы.

Но я-то не боюсь и могу обмануть компилятор, воспользовавшись goto вместо цикла, как в старые добрые времена.




И, как видите, теперь компилятор с радостью инлайнит нашу функцию! В итоге нам удаётся сэкономить около 2 микросекунд. Неплохо!



Второе узкое место несложно увидеть, если внимательно посмотреть на ассемблерный вывод. Компилятор добавил проверку на границы слайса прямо внутрь нашего самого горячего цикла. Дело в том, что Go — безопасный язык, компилятор опасается, что три моих аргумента (три слайса) имеют разный размер. Ведь тогда будет теоретическая возможность возникновения так называемого переполнения буфера (buffer overflow).

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

Видя это, компилятор с радостью пропускает проверку, а мы в итоге экономим ещё 500 наносекунд.

Большие батчи


Окей, нам удалось выжать какую-то производительность из нашей простой имплементации, но этот результат, на самом деле, гораздо хуже, чем возможно с текущим железом.

Всё, что мы делаем, — это базовые битовые операции, и наши процессоры очень эффективно их выполняют. Но, к сожалению, мы «кормим» наш процессор очень маленькими кусками работы. Наши функции выполняют операции побайтово. Мы можем очень просто подтюнить наш код, чтобы он работал с 8-байтовыми кусками, используя слайсы UInt64.



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


Имплементация на ассемблере



Но это ещё не конец. Наши процессоры могут работать с кусками по 16, 32 и даже 64 байта. Такие «широкие» операции называются single instruction multiple data (SIMD; одна инструкция, много данных), а процесс преобразования кода таким образом, чтобы он использовал такие операции, называется векторизация.

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



Ассемблер Go — странный зверь. Вы наверняка знаете, что ассемблер — это что-то, что сильно привязано к архитектуре компьютера, для которого вы пишете, но в Go это не так. Ассемблер Go больше похож на IRL (intermediate representation language) или промежуточный язык: он практически платформонезависимый. Роб Пайк выступил с отличным докладом на эту тему несколько лет назад на GopherCon в Денвере.

В дополнение к этому Go использует необычный формат Plan 9, отличающийся от общепризнанных форматов AT&T и Intel.

Можно с уверенностью сказать, что писать ассемблер Go вручную — это не самое весёлое занятие.

Но, к счастью, уже есть две высокоуровневые тулзы, которые помогают нам в написании Go ассемблера: PeachPy и avo. Обе утилиты генерируют Go-ассемблер из более высокоуровневого кода, написанного на Python и Go соответственно.

Эти утилиты упрощают такие вещи, как register allocation (выбор процессорного регистра), написание циклов, и в целом упрощают процесс входа в мир ассемблерного программирования в Go.

Мы будем использовать avo, так что наши программы будут почти обычными программами на Go.

Вот так выглядит самый простой пример avo-программы. У нас есть функция main(), которая определяет внутри себя функцию Add(), смысл которой заключается в сложении двух чисел. Здесь присутствуют вспомогательные функции для получения параметров по имени и получения одного из свободных и подходящих процессорных регистров. У каждой процессорной операции есть соответствующая функция на avo, как видно по ADDQ. И наконец, мы видим вспомогательную функцию для сохранения результирующего значения.

Вызвав go generate, мы выполним программу на avo и в итоге будут сгенерированы два файла:

  • add.s с результирующим кодом на Go-ассемблере;
  • stub.go с заголовками функций для связи двух миров: Go и ассемблера.


Теперь, когда мы увидели, что и как делает avo, давайте посмотрим на наши функции. Я реализовал и скалярную, и векторную (SIMD) версии функций.

Сначала посмотрим на скалярные версии.

Как и в предыдущем примере, мы просим предоставить нам свободный и правильный регистр общего назначения, нам не нужно вычислять смещения и размеры для аргументов. Всё это avo делает за нас.

Ранее мы использовали лейблы и goto (или прыжки) для повышения производительности и для того чтобы обмануть компилятор Go, но сейчас мы делаем это с самого начала. Дело в том, что циклы — это более высокоуровневое понятие. В ассемблере же у нас есть только лейблы и прыжки.

Оставшийся код должен быть уже знаком и понятен. Мы эмулируем цикл лейблами и прыжками, берём маленькую часть данных из двух наших слайсов, объединяем их битовой операцией (AND NOT в данном случае) и затем кладём результат в результирующий слайс. Всё.

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

Если сравнить производительность имплементации на ассемблере с производительностью лучшей имплементации на Go, то мы увидим, что она одинакова. И это ожидаемо. Ведь мы не делали ничего особенного — мы лишь воспроизвели то, что делал бы компилятор Go.

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

Именно поэтому невозможно получить какие-либо преимущества от мелких функций на ассемблере. Нам надо либо писать большие функции, либо использовать новый пакет math/bits, либо обходить ассемблер стороной.

Давайте теперь посмотрим на векторные версии наших функций.

Для данного примера я решил применить AVX2, поэтому мы будем использовать операции, работающие с 32-байтными кусками. Структура кода очень похожа на скалярный вариант: загрузка параметров, просьба предоставить нам свободный общий регистр и т. п.

Одно из новшеств связано с тем, что более широкие векторные операции используют специальные широкие регистры. В случае с 32-байтными кусками это регистры с префиксом Y. Именно поэтому вы видите функцию YMM() в коде. Если бы я использовал AVX-512 с 64-битными кусками, то префикс был бы Z.

Второе новшество связано с тем, что я решил использовать оптимизацию, которая называется разворачивание цикла (loop unrolling), то есть сделать восемь операций цикла вручную, прежде чем прыгнуть в начало цикла. Данная оптимизация уменьшает количество бранчей (ветвлений) в коде, и она ограничена количеством свободных регистров, имеющихся в наличии.

Ну а что насчёт производительности? Она прекрасна! Мы получили ускорение примерно в семь раз по сравнению с лучшим решением на Go. Впечатляет, да?

Но даже эту имплементацию потенциально можно было бы ускорить, воспользовавшись AVX-512, префетчингом или JIT (just-in-time compiler) для планировщика запросов. Но это уж точно тема для отдельного доклада.

Проблемы bitmap-индексов


Сейчас, когда мы уже рассмотрели простую реализацию bitmap-индекса на Go и гораздо более производительную на ассемблере, давайте, наконец, поговорим о том, почему же bitmap-индексы так редко используются.

В старых научных работах упоминаются три проблемы bitmap-индексов, но более новые научные работы и я утверждаем, что они уже неактуальны. Не будем глубоко погружаться в каждую из этих проблем, но поверхностно их рассмотрим.

Проблема большой кардинальности


Итак, нам говорят, что bitmap-индексы подходят только для полей с малой кардинальностью, то есть таких, у которых мало значений (например, пол или цвет глаз), и причина в том, что обычное представление таких полей (один бит на значение) в случае большой кардинальности будет занимать слишком много места и, более того, эти bitmap-индексы будут слабо (редко) заполнены.


Иногда мы можем использовать другое представление, например стандартное, которое мы используем для представления чисел. Но именно появление алгоритмов сжатия всё изменило. За последние десятилетия учёные и исследователи придумали большое количество алгоритмов сжатия для битмапов. Основное их преимущество в том, что не нужно расжимать битмапы для проведения битовых операций — мы можем совершать битовые операции прямо над сжатыми битмапами.

В последнее время стали появляться и гибридные подходы, как, например, roaring битмапы. Они используют одновременно три разных представления для битмапов — собственно битмапы, массивы и так называемые bit runs — и балансируют между ними, чтобы максимизировать производительность и минимизировать потребление памяти.

Вы можете встретить roaring битмапы в самых популярных приложениях. Уже существует огромное количество имплементаций для самых разных языков программирования, в том числе более трёх имплементаций для Go.

Ещё один подход, который может нам помочь справиться с большой кардинальностью, называется группировка (binning). Представьте, что у вас есть поле, представляющее рост человека. Рост — это число с плавающей запятой, но мы, люди, не думаем о нём в таком ключе. Для нас нет разницы между ростом 185,2 см и 185,3 см.

Получается, мы можем сгруппировать похожие значения в группы в пределах 1 см.

А если мы ещё и знаем, что очень мало людей имеют рост меньше 50 см и больше 250 см, то мы можем, по сути, превратить поле с бесконечной кардинальностью в поле с кардинальностью примерно в 200 значений.

Конечно, при необходимости мы можем сделать дополнительную фильтрацию уже после.

Проблема большой пропускной способности


Следующая проблема bitmap-индексов заключается в том, что их обновление может быть очень дорогим.

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

Эту проблему можно решить или обойти с помощью шардинга или использования версионированных индексов.

Шардинг — штука простая и общеизвестная. Вы можете шардировать bitmap-индекс так, как вы шардировали бы любые другие данные. Вместо одного большого лока вы получите кучу мелких локов и таким образом избавитесь от lock contention.

Второй способ решения проблемы — это использование версионированных индексов. У вас может быть одна копия индекса, которую вы используете для поиска или чтения, и одна — для записи или обновления. И раз в какой-то промежуток времени (например, раз в 100 мс или 500 мс) вы их дублируете и меняете местами. Конечно, этот подход применим только в тех случаях, когда ваше приложение может работать с чуть отстающим поисковым индексом.

Эти два подхода можно использовать одновременно: у вас может быть шардированный версионированный индекс.

Более сложные запросы



Последняя проблема bitmap-индексов заключается в том, что, как нам говорят, они плохо подходят для более сложных типов запросов, например запросов «по промежутку».

И правда, если подумать, битовые операции типа AND, OR и т. д. не очень подходят для запросов а-ля «Покажи мне отели со стоимостью номера от 200 до 300 долларов за ночь».

Наивным и очень неразумным решением было бы взять результаты для каждого долларового значения и объединить их битовой операцией OR.

Чуть более правильным решением было бы использовать группировку. Например, в группы по 50 долларов. Это ускорило бы наш процесс в 50 раз.

Но проблема также легко решается использованием представления, созданного специально для такого типа запросов. В научных работах оно называется range-encoded bitmaps.

В таком представлении мы не просто выставляем один бит для какого-либо значения (например, 200), а выставляем это значение и всё, что выше. 200 и выше. То же самое для 300: 300 и выше. И так далее.

Используя это представление, мы можем ответить на такого рода поисковый запрос, пройдя по индексу всего два раза. Сначала мы получим список отелей, где номер стоит меньше или 300 долларов, а затем выкинем из него те, где стоимость номера меньше или 199 долларов. Готово.

Вы удивитесь, но даже геозапросы возможны с использованием bitmap-индексов. Хитрость в том, чтобы использовать геопредставление, которое окружает вашу координату геометрической фигурой. Например, S2 от Google. Фигуру должно быть возможно представить в виде трёх или более пересекающихся прямых, которые можно пронумеровать. Таким образом мы сможем превратить наш геозапрос в несколько запросов «по промежутку» (по этим пронумерованным линиям).

Готовые решения


Надеюсь я немного вас заинтересовал и у вас в арсенале появился ещё один полезный инструмент. Если вам когда-нибудь нужно будет сделать что-то подобное, вы будете знать, в какую сторону смотреть.

Однако не у всех есть время, терпение и ресурсы, чтобы создать bitmap-индексы с нуля. Особенно более продвинутые, с использованием SIMD, например.

К счастью, есть несколько готовых решений, которые вам помогут.

Roaring битмапы


Во-первых, есть та самая roaring bitmaps-библиотека, о которой я уже говорил. Она содержит все необходимые контейнеры и битовые операции, которые вам понадобятся для того, чтобы сделать полноценный bitmap-индекс.

К сожалению, на данный момент ни одна из Go-реализаций не использует SIMD, а значит, Go-реализации менее производительны, чем реализации на C, например.

Pilosa


Другой продукт, который может вам помочь, — СУБД Pilosa, у которой, по сути, только bitmap-индексы и есть. Это относительно новое решение, но оно завоёвывает сердца с огромной скоростью.

Pilosa использует roaring битмапы внутри себя и даёт вам возможность использовать их, упрощает и объясняет все те вещи, о которых я говорил выше: группировка, range-encoded bitmaps, понятие поля и т. п.

Давайте быстренько взглянем на пример использования Pilosa для ответа на уже знакомый вам вопрос.

Пример очень похож на то, что вы видели раньше. Мы создаём клиент к серверу Pilosa, создаём индекс и необходимые поля, затем заполняем наши поля рандомными данными с вероятностями и, наконец, выполняем знакомый запрос.

После этого мы используем NOT на поле «expensive», затем пересекаем результат (или AND-им) с полем «terrace» и с полем «reservations». И наконец, получаем финальный результат.

Я очень надеюсь, что в обозримом будущем в СУБД вроде MySQL и PostgreSQL тоже появится этот новый тип индексов — bitmap-индексы.

Заключение



Если вы ещё не заснули, спасибо. Мне пришлось вскользь затронуть многие темы из-за ограниченного количества времени, но я надеюсь, что доклад был полезным и, может, даже мотивирующим.

О bitmap-индексах хорошо знать, даже если прямо сейчас они вам не нужны. Пусть они будут ещё одним инструментом в вашем ящичке.

Мы с вами рассмотрели различные трюки увеличения производительности для Go и те вещи, с которыми компилятор Go пока не очень хорошо справляется. А вот это абсолютно точно полезно знать каждому программисту на Go.

Это всё, что я хотел рассказать. Спасибо!

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


  1. dasenkiv
    16.05.2019 15:43
    +5

    Рад, что нашел эту статью на хабре. Недавно был на митапе, где выступал Марко с этим докладом, довольно понятно разложено для тех, кто вообще не знаком с Bitmap-индексами.
    Спасибо.


  1. niya3
    16.05.2019 15:58
    +1

    jfyi: на слайде What is a Bitmap Index? случайный набор нулей и единичек :)


    1. mkevac Автор
      16.05.2019 15:58

      Да :-)


      1. dasenkiv
        16.05.2019 17:49

        Это самый популярный вопрос к этому докладу :)


        1. mkevac Автор
          16.05.2019 17:51

          Еще был «почему вы делаете доклад на английском на русскоязычной конференции?»…


  1. advolkov
    16.05.2019 16:23

    check
    image
    Второй запрос немного сложнее. Нам необходимо использовать битовую операцию NOT на битмапе «дорогой», чтобы получить список недорогих ресторанов, затем за-AND-ить его с битмапом «можно забронировать столик» и за-AND-ить результат с битмапом «есть веранда». Результирующий битмап будет содержать список заведений, которые соответствуют всем нашим критериям. В данном примере это только ресторан «Юность».
    image


    1. mkevac Автор
      16.05.2019 16:24
      +1

      Ох. Кажется я слишком хотел, чтобы это оказалось заведение с вкуснейшими наливками, Юность!


  1. VlK
    16.05.2019 16:28
    +2

    Спасибо за статью!

    Я работал с roaring bitmaps в Си, поразительная штука, и, как мне кажется, недостаточно утилизированная в БД.

    Но вы меня сильно удивили насчет варианта на Go. Один из авторов оригинальной либы в ее Си исполнении, некий Даниэль Лемирэ, — мирового уровня специалист по векторизации алгоритмов. Собственно, производительность библиотеки (на Си) определяется во многом именно использованием SIMD.

    Неужели вариант на Го настолько слабей..? Вы как-нибудь сравнивали производительность? Я не смотрел код, но удивлюсь, если roaring на Go вообще обходится без SIMD.


    1. mkevac Автор
      16.05.2019 16:35
      +1

      Обожаю статьи Даниэля и вообще все то, что он делает в области оптимизаций. Специалист мирового уровня и правда!

      Напрямую си и go версии я не сравнивал. В репозитории что к докладу есть сравнение cgo и go варианта, но это не совсем то.

      В go реализациях в тикетах и пулл реквестах закопано несколько попыток добавить SIMD, но этот код, на сколько мне известно, еще никуда не замержен и нигде не используется.

      Думаю что разработчики pilosa так или иначе в ближайшее время придут к тому, что без SIMD не обойтись. Идея и необходимость прямо витает в воздухе и, я уверен, скоро материализуется.


      1. VlK
        16.05.2019 16:41
        +1

        Да, Даниэль молодец, даже его научные статьи читать — одно удовольствие.

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

        А если шире. Почему в roaring bitmaps на Го могли опустить SIMD? Я не слишком хорошо знаком с языком, чисто шапочно. Там есть что-то аналогичное SIMD intrisics в Си? Или надо на ассемблере делать?


        1. mkevac Автор
          16.05.2019 16:49

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

          За ассемблер сейчас чаще всего берутся, когда пишут шифрование.
          Появление avo/peachpy чуть приоткроет этот мир для более широкого круга лиц.


          1. VlK
            16.05.2019 17:08

            Ну… Речь даже не об ассемблере идет, а о встроенных функциях, которые позволяют не писать непосредственно на асме, но все же использовать конкретные инструкции при необходимости в рамках Си.

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

            А автовекторизация — священный грааль мира компиляторов. В сущности, никто не знает, как это правильно делать в нетривиальных случаях. Не думаю, что разработчики Го тут что-то неожиданное придумают.

            Спасибо за ответы!


            1. RPG18
              16.05.2019 17:23

              А что делать, если найдется amd64 без AVX инструкций?


              1. VlK
                16.05.2019 17:46

                ifdef или какой-нибудь аналог типа флагов времени компиляции?


                Обычно делают скалярный вариант на случай CPU без SIMD вообще, и потом векторные — для актуальных процессоров. Или даже несколько: для ARM NEON, разных поколений Intel и т.д.


                Удовольствия в этом мало, конечно. :-) Но когда оно надо, то оно надобно очень.


                1. RPG18
                  16.05.2019 18:12

                  Представляю ситуацию. Заходит пользователь скачать minio, для файл помойки на core2quad, а там сборка под amd64, amd64-avx и т.д.


                  1. VlK
                    16.05.2019 18:43
                    +1

                    У вас это практические вопросы или риторические?


                    Сто лет в обед как единожды собранная glibc использует разные версии функций при использовании на разных машинах.


                    Если не подходят ifdef-ы, можно на лету выбирать необходимые функции: можно динамически линковать разные версии функций в зависимости от железа или даже атрибутами указывать под поколения процессоров версии одной и той же функции (https://lwn.net/Articles/691932/). В конце концов, можно вот прям кодом спросить есть ли у процессора соответствеующий набор инструкций.


                    1. RPG18
                      16.05.2019 19:04
                      +1

                      Там не было вопроса и это нормальный кейс. И тут не gcc.


                    1. RPG18
                      16.05.2019 19:15

                      Хотя признаю, что можно написать свич


  1. Labunsky
    16.05.2019 17:44

    Если уж хочется оптимизировать, то почему не использовать для сравнения gccgo, который куда охотнее оптимизирует пользовательский код?


    1. VlK
      16.05.2019 17:48

      Реально серьезная оптимизация алгоритмов компиляторами не делается, а делается людьми :-)


      1. Labunsky
        16.05.2019 17:52

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


    1. mkevac Автор
      16.05.2019 17:59

      gccgo это такая штука, о которой вроде бы все слышали, но которая почти никому не нужна и не интересна. Личный pet project Ian Lance Taylor, который жив только благодаря ему.
      99% работы и активности приходится на основной компилятор.


      1. Labunsky
        16.05.2019 18:12

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


  1. bat
    16.05.2019 18:53
    +2

    наконец дождался статьи!
    спасибо!


  1. bugsfinder
    16.05.2019 21:02

    А есть какие-то сравнительные тесты, которые показывают, как использование bitmap-индексов позволяет ускорить работу приложения по сравнению с "обычными" индексами?


    1. VlK
      17.05.2019 09:36

      Надо уточнить, какие именно операции вы хотите сравнить. Все-таки bitmap по определению это операции над множествами, что-то с ними принципиально не сделать.


      Но если речь идёт об операциях вида and/or над множеством ключей, скажем, к таблице, то оно будет в десятки раз быстрей.


      На простом Си пока ничего лучше roaring bitmap не придумали, и есть очень читаемая публикация в свободном доступе на эту тему. Там есть сравнения.


    1. saterenko
      17.05.2019 14:40

      Всё таки bitmap-индексы это не «классически» индексы, и тот факт, что обе структуры называются индексами может вводить в заблуждение. Я использую битмапы для выбора баннера в системе управления рекламой. В этой задаче практически невозможно использовать индексы, потому как надо проверять десятки ограничений для каждого из баннеров.

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


  1. Peter03
    17.05.2019 12:26

    Насколько я помню в БД bitmap-индексы начали появляться в середине-конце 90-х. Проблема в том что обновления, особенно сжатых индексов довольно затратная операция.

    Ну и основная причина, это то что подавляющее большинство работает с объёмами данных, для которых и B-tree индексы перформят «good enough».

    Ну и там где они действительно будут показывать чудеса быстродействия (объединение сравнений нескольких bitmap-индексов), тоже не повсеместно распространнёная фича.


    1. VlK
      17.05.2019 18:14

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


      Последние двадцать лет исследования в этой области сводились прежде всего к методам эффективного сжатия и распаковки таких индексов. У Оракла, кстати, есть патентованный алгоритм на эту тему; где-то есть обзорная научная публикация со сравнением традиционных методов и их производительности.


      С распространением инструкций SIMD и расширением их возможностей появилось новое поколение алгоритмов, работающих с битмапами, которые практически разрешили основные из проблем битмапов. Прежде всего это roaring bitmaps, которые эффективно сжимаются/распаковываются, и на современных машинах работают поразительно эффективно.


      Но вы зря про редкость объединений. Они в типичных запросах к бд встречаются постоянно.


      1. Peter03
        17.05.2019 22:04

        Но вы зря про редкость объединений. Они в типичных запросах к бд встречаются постоянно.
        bitmap-индексы имеет смысл создавать для полей с небольшим количеством возможных значений (я думаю до 1% уникальных значений от общего количества записей) и для достаточно больших таблиц для которых full-scan довольно затратен.

        Типа пол человека или отдел в котором он работает и т.п.

        Например для даты/времени уже будет не эффективен. Только для даты, может быть, но кластеризация по дате, может быть более эффективна с точки зрения IO (хотя с приходом SSD это уже менее актуально).

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

        Ad-hoc отчёты конечно выиграют больше, но опять же сильно зависит от ваших данных.

        Bitmap index от кастомной функции может очень хорошо работать, но тут упирается в то как вы эту функцию напишите.

        SIMD это хорошо, но в случае с БД любой дополнительный IO настолько больше ресурсов требует, что любые выигрыши будут малозаметны.

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


        1. VlK
          20.05.2019 10:07

          Вы совершенно правы насчет главного ограничения индексов на битмапах: пространство значений типа колонки должно быть небольшой размерности. Битмапы — не универсальное решение. Я лишь хочу сказать, что в местах, где их можно использовать (операции над множествами), они будут вне конкуренции.


          SIMD это хорошо, но в случае с БД любой дополнительный IO настолько больше ресурсов требует, > что любые выигрыши будут малозаметны.

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


          Но есть два "но"! :-) Во-первых, современные битмапы сильно ужаты, и умещаются в единицы кэш-линий. Для винта, тем более — SSD, это чтение одной страницы памяти; в то время как B-деревья трогают по крайней мере пару страницу.


          Во-вторых, последнее поколение БД (https://en.wikipedia.org/wiki/NewSQL) использует винт исключительно для хранения лога (WAL), а данные хранятся почти полностью в памяти, и вот тут уже действуют совсем другие правила, и всякие там ускорения вычислений (векторизация запросов, jit-компиляция запросов, различные кеш-ориентированные индексы, безлоковые структуры данных) играют важную роль.


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

          Ну да, разумеется. Развитые структуры данных — дело такое, перед применением надо основательно разобраться с преимуществами или недостатками.


          B-дерево — прекрасная и почти универсальная структура данных, я спорить тут не могу, но есть множество альтернатив, которые в отдельных слуаях будут себя показывать лучше. Например, MemSQL вообще решили обойтись скип-листом для своих индексов, т.к. решили, что корректно сделать безлоковое B-дерево им не по карману.


          Я все это не на ровном месте говорю. Мы используем roaring bitmap для ускорения некоторых запросов к специлизированному индексу и удивительным образом он на профилях даже не появляется, памяти почти не ест. Данные у нас всегда в памяти, запросы часто фильтруют данные по полям малой размерности...


  1. edo1h
    18.05.2019 01:02

    Не сказан главный недостаток таких индексов: сложность поиска по ним O(n) (линейно зависит от размера просматриваемой таблицы), тогда как деревья дают O(log n), а хэши чуть ли не O(1).
    Если в таблице с миллиардами записей только 10 удовлетворяют условию, то нам придётся просмотреть весь индекс, в то время, как поиск по дереву пробежит только очень небольшую часть индекса.
    Для разреженных данных проблема может быть решена сжатием, о котором писалось в статье, но тут возникают сложности с модификацией данных.


    1. VlK
      18.05.2019 22:24

      Эм.


      Современные варианты битмапов ни в коем случае не будут держать все необходимые для сравнения миллиарды бит.