# Calculate bloom filter and page size automatically Tarantool, Вильданов Эмир, лето-осень 2022 [Link to GSoC project](https://github.com/tarantool/tarantool/wiki/Tarantool-Summer-of-Code-2022-ideas#calculate-bloom-filter-and-page-size-automatically) ## Связанные issues Bloom filter: * [Set bloom_fpr automatically #3969](https://github.com/tarantool/tarantool/issues/3969) Page size **(пока что работа над page size отложена)**: * [Introduce vinyl_primary_key_page_size #3366](https://github.com/tarantool/tarantool/issues/3366) * [vinyl: automatic page size based on page cache size #2158](https://github.com/tarantool/tarantool/issues/2158) * [vinyl: dynamic page size #2128](https://github.com/tarantool/tarantool/issues/2128) ## Описание проблемы Фильтр Блума -- это вспомогательная вероятностная структура данных, позволяющая уменьшить количество чтений с диска ( особенно несуществующих значений). С принципом работы стандартного и усовершенствованного фильтра (используемого в Tarantool'e) можно ознакомиться в [статье](http://algo2.iti.kit.edu/singler/publications/cacheefficientbloomfilters-wea2007.pdf). Цитата из документации ( раздел "[Хранение данных с помощью vinyl](https://www.tarantool.io/ru/doc/latest/book/box/engines/vinyl/#bloom-filters)"): > Единственный параметр, который можно менять независимо для каждого индекса, называется vinyl_bloom_fpr (FPR в данном случае означает сокращение от «false positive ratio» – коэффициент ложноположительного срабатывания), который по умолчанию равен 0,05, или 5%. На основе этого параметра Tarantool автоматически строит фильтры Блума оптимального размера для поиска как по полному ключу, так и по компонентам ключа. Сами фильтры Блума хранятся вместе с постраничным индексом в файле .index и кэшируются в оперативной памяти. Сопоставляется фильтр каждому хранящемуся на диске run'у. В момент восстановления space'а (после перезагрузки Tarantool'a) или при создании индекса метаинформация (в том числе и фильтры) о run'ах загружается в память. В данный момент на каждом уровне LSM-дерева всем run'ам сопоставляются bloom-фильтры с одинаковыми fpr. Предполагается, что данная реализация является не самой эффективной. ## Предложение Предложение по оптимизации строится на результатах, приведённых в [статье](https://dl.acm.org/doi/pdf/10.1145/3035918.3064054). Её авторы утверждают, что более оптимальная с точки зрения уменьшения объёма занимаемой памяти и уменьшения затрат на "паразитное" чтение стратегия заключается в присваивании разных fpr на разных уровнях. А именно предлагается присваивать fpr прямо пропорционально размеру run'ов на уровнях LSM-дерева: run'ам на более глубоких уровнях предлагается сопоставлять фильтры с более высоким fpr: > the optimal FPRs are proportional to the number of entries in each level Логика такая: на последних уровнях блум-фильтры занимают много места, поскольку размер блум фильтра растёт прямо пропорционально количеству записей в ран файле. Мы увеличим fpr на нижних уровнях, но уменьшим его на более высоких уровнях. Мы незначительно проиграем по количеству паразитных чтений с диска на нижних уровнях (незначительно, потому что при чтении с диска мы пользуемся указателями на конкретную страницу), при этом уменьшим количество ложноположительных срабатываний фильтра на более высоких уровнях, содержащих самую "свежую" информацию. ## Алгоритм вычисления новых fpr ### Алгоритм из статьи: Предлагаемый алгоритм основывается на выбранном пользователем параметре R (условные затраты на выполнение операции READ) , который лежит в диапазоне от 0 до L * (T - 1), где L -- это количество уровней в LSM-дереве, а T -- это параметр соотношения размеров файлов на каждом уровне (`vinyl_run_count_per_level` + 1 в Tarantool). ![algorithm](https://i.imgur.com/4cfx9dV.png) На изображении выше приведены формулы для вычисления оптимальных fpr для соответствующих уровней. Здесь L_filtered и L_unfiltered -- это число уровней с фильтрами и без соответственно. Значения R = 0 и R = L * (T - 1) соответствуют случаям, когда фильтрами покрыты все уровни LSM-дерева и когда фильтрами не покрыты никакие уровни. ### Алгоритм, адаптированный под размеры ран файлов: ![adopted_algorithm](https://i.imgur.com/wfcPQvg.png) **NOTE:** [main formula explanation](https://www.khanacademy.org/math/multivariable-calculus/applications-of-multivariable-derivatives/constrained-optimization/a/lagrange-multipliers-examples) В формуле подразумевается, что последний ран файл имеет наибольший размер. На самом деле, это не всегда так: например, при увеличении значения `vinyl_memory` размер ран файла, образовавшегося после дампа может сильно превысить размеры всех существующих ран файлов. Однако, если посмотреть, на реализацию функции `vy_range_update_compaction_priority`, можно понять, что в таком случае будет вызван так называемый *major compaction*, то есть все ран файлы объединятся в один: 1. `target_run_size` приравнивается размеру нового ран файла, поскольку`size < first_slice.bytes`; 2. Дойдя до последнего ран файла цикл `while (size > target_run_size)` не совершает итерацию и на последнем уровне оказывается больше одного ран файла. При обнаружении такого случая на момент вычисления оптимального `bloom_fpr`, достаточно присвоить ран файлу стандартное значение. Ситуация, когда происходит дамп, а за ним не следует *major compaction* неосуществима при условии, что размер `vinyl_memory` достаточно велик. ## Реализация Для реализации оптимизации было рассмотрено несколько вариантов, ниже будет приведён конечный вариант. Два варианта изменений, которые были отмечены как более трудоёмкие и нерациональные будут приведены в конце документа под заголовком **Первоначальная реализация**. ```c double vy_task_find_range_bloom_fpr( struct vy_lsm *lsm, struct vy_range *range, uint64_t new_run_stmts_count, int W, uint64_t total_stmts_count, int skip_n) { double R = W * lsm->opts.lookup_cost_coeff; if (new_run_stmts_count == total_stmts_count) { /** By definition of R. May also look at formulas ahead. * R <= 1 in this case as soon as W = 1. * It also means that skip_n == range->slice_count. */ return R; } struct vy_slice *last_slice = rlist_last_entry(&range->slices, struct vy_slice, in_range); uint64_t last_slice_stmts_count = last_slice->count.rows; if (new_run_stmts_count > last_slice_stmts_count) { /** The only case is when the size of dumped run * is bigger than the size of the last run. * Returning default bloom_fpr value, * cause otherwise formula breaks. * update_compaction_priority will understand that * major compaction must be called and compaciton will create * new run with optimal bloom_fpr */ return lsm->opts.bloom_fpr; } uint64_t scnd_last_slice_stmts_count = 0; if (skip_n == range->slice_count - 1) { /** In both dump and compaction cases it means * we only have the new run except the last run. */ scnd_last_slice_stmts_count = new_run_stmts_count; } else { struct vy_slice *scnd_last_slice = rlist_prev_entry( last_slice, in_range); scnd_last_slice_stmts_count = scnd_last_slice->count.rows; } int W_u = 0; int W_f = 0; double t_r = 0; if (R <= (total_stmts_count / last_slice_stmts_count)) { W_u = 0; } else { t_r = (double)scnd_last_slice_stmts_count / last_slice_stmts_count; W_u = MAX(0, floor((R - t_r * W) / (1 - t_r))); } if (W_u == W) { /** As soon as all runs should be unfiltered. */ return 1; } W_f = W - W_u; int slice_number = 0; uint64_t filtered_stmts_count_sum = new_run_stmts_count; struct vy_slice *slice; rlist_foreach_entry(slice, &range->slices, in_range) { if (slice_number++ < skip_n) { continue; } filtered_stmts_count_sum += slice->count.rows; if (slice_number == W_f) { break; } } double bloom_fpr = (R - W_u) * new_run_stmts_count / filtered_stmts_count_sum; return bloom_fpr; } /* * Logic: * Let's consider our dump is divided evenly into all lsm ranges. * So everything we have to do is to find optimized bloom_fpr * for small dump part in one of lsm ranges. * * @param lsm * @param new_run_stmts_count statements count in the newly created after * dump run * @return optimally calculated bloom_fpr for dump */ double vy_task_find_lsm_bloom_fpr( struct vy_lsm *lsm, uint64_t new_run_stmts_count) { struct vy_range *range = vy_range_tree_first(&lsm->range_tree); uint64_t part_run_stmts_count = new_run_stmts_count / lsm->range_count; int W = range->slice_count + 1; int total_stmts_count = range->count.rows + part_run_stmts_count; return vy_task_find_range_bloom_fpr( lsm, range, part_run_stmts_count, W, total_stmts_count, 0); } ``` Параметр `new_run_stmts_count` вычисляется в процессе создания нового ран файла и наполнения `vy_stmt_stream` при дампе и слиянии. ### bloom_fpr как пользовательский параметр Нынешний вариант реализации фильтров Блума позволяет пользователям самим выбрать параметр `bloom_fpr`, который будет применён при создании фильтров на всех уровнях. Было принято решение оставить возможность задания данного параметра для одного* конкретного случая: в функции `vy_lsm_recover_run` при восстановлении LSM-дерева из логов также создаётся новый run, для которого впоследствии в функции `vy_run_rebuild_index` нужно построить фильтр. Сейчас параметр `bloom_fpr` в ней берётся из структуры `index_opts`. В документации необходимо отразить тот факт, что теперь это поле применяется только для одного случая. Код С: ```c /** Index options */ struct index_opts { ... /* Bloom filter false positive rate. * Used only as default parameter when rebuilding index * See vy_run_rebuild_index() */ double bloom_fpr; ... } ``` ### R как пользовательский параметр Вместо выбора пользовательского параметра `bloom_fpr` пользователю теперь доступен параметр `lookup_cost_coeff`, который так же варьируется от 0 до 1. Код Lua: * Добавить параметр в `index_options` файла schems.lua; * Изменить `default_cfg` файла load_cfg.lua; * Добавить в тесты lua проверку на ограничения по новому параметру. Код C: * Добавить проверку в `index_opts_decode` alter.cc; * Добавить проверку в `box_check_vinyl_options` box.cc; * Добавить параметр в index_def.c и index_def.h. ### Создание фильтров с fpr, зависящем от уровня Поскольку главной идей оптимизации является вычисление оптимального параметра fpr для каждого уровня LSM-дерева остаётся передать новый вычисленный параметр fpr в функцию `tuple_bloom_new`. ### Benchmark Для проверки работоспособности внесённых изменений потребовалось написать бенчмарк, который проверит: * Изменилась ли производительность CRUD операций (не должна измениться) * Изменилось ли потребление оперативной памяти (должно уменьшиться) Изначально была предпринята попытка переписать [бечмарк](https://github.com/tarantool/tarantool/pull/7083/files) на чистом C++ от @CuriousGeorgiy. Однако на момент запуска в серии вызовов функций `vy_run_reader_f`, `cpipe_create` , `cbus_find_endpoint_locked` возникала runtime-ошибка. Есть предположение, что на момент инициализации окружения для движка vinyl не были вызваны необходимые функции. В итоге было принято решение видоизменить [бенчмарк](https://github.com/Korablev77/tarantool/commit/2c3b217b28edf07585161e8e456caf6000e6ea7a) от @Korablev77, написанный на Lua + C: на Lua проще провести инициализацию окружения, а на C++ провести замеры по времени. ## Зафиксированные трудности * Размер L0 не фиксирован, даже при дампе L0 с одинаковым параметром vinyl_memory размер получившегося рана может меняться. * Размер vinyl_memory должен быть больше чем 100 Mb: * [code comment](https://github.com/tarantool/tarantool/blob/7ec71b4f6b0a15997553529c8e15c2dd415df44f/src/box/vy_regulator.c#L97-L106) * > @curiousgeorgiy: у тебя скорость записи в память превышает скорость записи на диск, и в итоге у тебя винил, не успев задампить, начинает триггерить ещё один дамп ## Вопросы и замечания * **В `dump_new` нет range** * На самом деле в LSM-дереве несколько ranges. При дампе ран файл попадает во все ranges, которые покрывают его. То есть для каждого range необходимо добавить ран файл со своим фильтром? * При coalesce и split вызывается перерасчёт compaction_priority. * Использование функции MAX можно найти в vy_run.c : "run->info.max_lsn = MAX(run->info.max_lsn, lsn);" * Использование функции log() можно найти в bloom.c : "ceil(log(false_positive_rate) / log(0.5));" * Есть ли статистика по тому, насколько высокими могут получаться деревья? * Вопрос связан с тем, что на примере big_insert получалось так, что до создания уровня L3 не доходило, потому что дерево делилось на несколько ranges. * Изучить процесс `vy_run_recover` и `vy_run_info_decode`, чтобы понять, какому range он сопоставляется. * Бесполезные подсказки: * > error: 'builtin/box/schema.lua:1821: Use space:select(...) instead of space.select(...)' * > error: 'builtin/box/schema.lua:1821: Use space:update(...) instead of space.update(...)' * Иногда логи выводятся в интерактивную строку (после tarantool>) * Маленькая наблюдаемая скорость дампа: * > dumping 88667998 bytes, expected rate 200.0 MB/s, ETA 0.4 s, write rate (avg/max) 2.3/24.4 MB/s ## Первоначальная реализация ### Варианты вычисления уровня ран файла и количества уровней в LSM-дереве Уровни в LSM-дереве, представленные в виде пирамиды -- это лишь абстракция для удобного восприятия этой структуры: по факту каждый range хранит slice'ы, ссылающиеся на соответствующе run'ы в порядке убывания их lsn в виде массива. В коде единственное место, где происходит выявление уровня run'а -- это функция `vy_range_update_compaction_priority`: ```c ... while (size > target_run_size) { /* * The run size exceeds the threshold * set for the current level. Move this * run down to a lower level. Switch the * current level and reset the level run * count. */ level_run_count = 1; ... ``` На данный момент предложено два варианта по определению уровня конкретного ран файла и количеству уровней в дереве: 1. Скопировать логику определения уровня из функции выше в место, где происходит создание нового ран файла: 1. Поскольку размер дампов L0 не равен размеру `vinyl_memory` (из-за затрат на вспомогательные структуры данных) и не зафиксирован (из-за того, что от запуска к запуску пользователь может изменять параметр `vinyl_memory`), необходимо высчитывать его с помощью скользящего среднего и сохранять в логах информацию о размере L0. 2. С помощью вычисленных из алгоритма значений `target_run_size` и `last_run_size` (можно получить напрямую из slice'a) найти значения `run_level_number` и `tree_levels_number` по формулам: 1. `run_level_number = ceil(log(target_run_size / L0_size, run_size_ratio))` 2. `tree_levels_number = ceil(log(last_run_size / L0_size, run_size_ratio))` 2. Воспользоваться полем `dump_count` у класса `vy_run`: 1. Поскольку это поле фиксирует количество дампов, которое необходимо совершить для получения конкретного ран файла, можно получать значение `run_level_number` по формуле `run_level_number = ceil(log(run->dump_count, run_size_ratio))`. 2. Аналогично значение `tree_levels_number` можно получить по формуле `run_level_number = ceil(log(last_run->dump_count, run_size_ratio))`. У обоих вариантов есть свои недостатки: | Вычисление с помощью `target_run_size` | Вычисление с помощью `dump_count` | |-----------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------| | Погрешность вычисления `target_run_size` и `L0_size` | Зависимость* от погрешности вычислений в функции `vy_range_update_compaction_priority` | | Необходимость добавления в классы, связанные с LSM-деревьями, новых полей для работы с параметрами `L0_size` и `tree_levels_number` | Особый** алгоритм вычисления `dump_count` последнего уровня дерева | | Необходимость сохранения в .xlog файлы информации о новых полях для обеспечения персистентности | | *Поле `dump_count` обновляется в функции `vy_task_compaction_new` на основании вычисленного в функции `vy_range_update_compaction_priority` показателя `compaction_priority`. **Из документации: ```c * If the run was produced by a major compaction, it * is is the sum of dump counts of compacted runs * minus the dump count of the last (greatest) run. ``` Поскольку на последнем уровне всегда хранится только один ран файл и поскольку при слиянии вычитается показатель `dump_count` последнего ран файла может возникнуть следующая ситуация: ![Пример вычисления `dump_count` на последнем уровне LSM-дерева](https://i.imgur.com/4hd8JVd.png) Можно заметить, что при применении формулы вычисления уровня для последнего ран файла получится значение 2, что не соответствует действительности. ### Присвоение run'ам уровня В ходе работы движка vinyl новый для записи ран файл создаётся в следующих местах: 1. при осуществлении dump'а через вызов функции `vy_task_dump_new`; 2. при слиянии нескольких run'ов через вызов функции `vy_task_compaction_new`; [//]: # (3. при восстановлении run'а в функции `vy_lsm_recover_run`;) [//]: # (4. при вызове функции `vy_run_prepare` через ``) [//]: # (5. при тестировании в функции `test_basic`) В обоих случаях логика исполнения задач (task'ов) разделяется на несколько этапов: 1. Подготовительный, где task инициализируется необходимой информацией; 2. `.execute`, где происходит процесс создания нового run путём вызова функции `vy_task_write_run`; 3. `.complete`, где, например, происходит работа с логами. Функция по вычислению оптимального fpr: ```c double vy_task_calculate_optimal_bloom_fpr(struct vy_lsm *lsm, /* Подход 1. */ uint64_t target_run_size /* Подход 2. */ uint32_t run_dump_count, uint32_t levels_number) { /* Подход 1. */ uint32_t levels_number = lsm->levels_number; uint64_t run_level = log_a_to_base_b(target_run_size / lsm->L0_size, lsm->opts.run_size_ratio); /* Подход 2. */ uint64_t run_level = log_a_to_base_b(run_dump_count, lsm->opts.run_size_ration); double R = (levels_number * lsm->opts.run_count_per_level) / 2; uint32_t L_filtered = levels_number - max(0, (int)((R - 1) / lsm->opts.run_count_per_level)); if (run_level > L_filtered) { return 1; } uint32_t L_unfiltered = levels_number - L_filtered; return (R - L_unfiltered * lsm->opts.run_count_per_level) / (pow(lsm->opts.run_count_per_level, L_filtered + 1 - run_level)); } ``` Предложенные варианты изменений: #### С помощью `target_run_size`: 1. Создать в структурах `vy_lsm_recovery_info`, `vy_log_record` и `vy_lsm` новые поля: ```c /** Size of L0 dump. */ uint64_t L0_size; /** Number of levels in LSM-tree. */ uint32_t levels_number; ``` **ЗАМЕЧАНИЕ:** Нужно персистить уровень не LSM-дерева, а каждого его range'a, поскольку именно они представляют собой отдельные деревья. Выглядит так, будто это дороговато. 2. Добавить в файл *vy_lsm.c* функцию `vy_lsm_update_L0_size` по пересчёту `L0_size` с помощью скользящего среднего. 3. Поддержать сохранение информации об `L0_size` и `levels_number` в записи `VY_LOG_DUMP_LSM` и её последующее восстановление: ```c enum vy_log_key { VY_LOG_KEY_L0_SIZE = 17, VY_LOG_KEY_LEVELS_NUMBER = 18, }; vy_log_record_decode(struct vy_log_record *record, struct xrow_header *row) { case VY_LOG_KEY_L0_SIZE: record->L0_size = mp_decode_uint(&pos); break; case VY_LOG_KEY_LEVELS_NUMBER: record->levels_number = mp_decode_uint(&pos); break; } vy_recovery_dump_lsm(struct vy_recovery *recovery, int64_t L0_size, int32_t levels_number) { ... lsm->L0_size = L0_size; lsm->levels_number = levels_number; } case VY_LOG_DUMP_LSM: rc = vy_recovery_dump_lsm(recovery, record->lsm_id, record->L0_size, record->levels_number); struct vy_log_record { ... /** Size of L0 dump. */ uint64_t L0_size; /** Number of levels in LSM-tree. */ uint32_t levels_number; }; struct vy_lsm_recovery_info { ... /** Size of L0 dump. */ uint64_t L0_size; /** Number of levels in LSM-tree. */ uint32_t levels_number; }; vy_log_dump_lsm(int64_t id, int64_t dump_lsn, int64_t L0_size, int32_t levels_number) { record.L0_size = L0_size; record.levels_number = levels_number; } ``` 4. Для каждого случая создания ран файла посчитать два параметра `run_level_number` и `tree_levels_number`: - В случае дампа: ```c vy_lsm_update_L0_size(lsm, new_run_size); if (lsm->levels_number == 0) { lsm->levels_number = 1; } uint64_t target_run_size = lsm->L0_size * lsm->opts.run_size_ratio; task->bloom_fpr = vy_task_calculate_optimal_bloom_fpr(lsm, target_run_size); ``` Где `new_run_size` вычисляется из размера участков памяти, с которых и будет происходить дамп: ```c uint64_t new_run_size = 0; rlist_foreach_entry(mem, &lsm->sealed, in_sealed) { ... new_run_size += mem->count.bytes; } ``` - В случае слияния: ```c uint64_t target_run_size; uint64_t size; struct vy_slice *last_slice; struct vy_slice *first_slice; last_slice = rlist_last_entry(&range->slices, struct vy_slice, in_range); size = MAX(last_slice->count.bytes, 1); first_slice = rlist_first_entry(&range->slices, struct vy_slice, in_range); do { target_run_size = size; size = DIV_ROUND_UP(target_run_size, lsm->opts.run_size_ratio); } while (size > (uint64_t)MAX(first_slice->count.bytes, 1)); ... lsm->levels_number = log_a_to_base_b(last_slice->count.bytes / lsm->L0_size, lsm->opts.run_size_ratio); task->bloom_fpr = vy_task_calculate_optimal_bloom_fpr(lsm, target_run_size); ``` #### С помощью `dump_count`: И в случае дампа, и в случае слияния передать в функцию значения `dump_count` и `levels_number`: ```c ... struct vy_slice *last_slice = rlist_last_entry(&range->slices, struct vy_slice, in_range); uint32_t levels_number = log_a_to_base_b(last_slice->run->dump_count, lsm->opts.run_size_ratio); task->bloom_fpr = vy_task_calculate_optimal_bloom_fpr(lsm, new_run->dump_count, levels_number); ... ``` **ЗАМЕЧАНИЕ**: в функции `vy_task_dump_new` мы не работаем с конкретным `range`. Нужно назначать ран файлу дефолтное значение fpr, которое поменяется на этапе слияния.