
«Преждевременная оптимизация — корень всех зол», — Дональд Кнут
Оглавление
Глава 1: Разрыв в производительности
Глава 3: Бенчмаркинг и профилирование
Глава 4: Массивы и локальность кэша
Глава 5: Связанные списки — убийцы кэша
Глава 7: Хэш-таблицы и конфликты кэша
Глава 8: Динамические массивы и управление памятью
Глава 9: Двоичные деревья поиска
Глава 10: B-деревья и деревья, эффективно использующие кэш
Глава 11: Префиксные деревья и базисные деревья
Глава 12: Кучи и очереди с приоритетом
Глава 13: Структуры данных без блокировок
Глава 14: Обработка строк и эффективность использования кэша
Глава 15: Графы и их обход с эффективным использованием кэша
Глава 16: Фильтры Блума и вероятностные структуры данных
Кризис памяти
Наш веб-краулер потреблял 128 МБ ОЗУ только на отслеживание посещённых URL. На встраиваемом устройстве с 256 МБ это была половина всей памяти.
Задача краулера была простой: отслеживать посещённые URL, чтобы не краулить одну и ту же страницу дважды. После обработки 1 миллиона URL (средняя длина 80 байт) хэш-таблица, в которой хранились эти URL, разрослась до 96 МБ плюс оверхед.
«Можем ли мы обменять точность на память? Нас вполне устроит несколько дублированных операций, если это позволит сэкономить большой объём памяти», — сказал мне мой менеджер во время ревью кода.
Этот вопрос изменил всё. На самом деле, идеальная точность не требуется. Если мы случайно обработаем одну страницу дважды, то впустую потратим часть пропускной способности, но ничего не поломаем. Главным ограничением была память.
В нашем исходном решении использовалась простая хэш-таблица:
hash_table_t *visited_urls; // Хранит полные URL bool is_visited(const char *url) { return hash_table_contains(visited_urls, url); } void mark_visited(const char *url) { hash_table_insert(visited_urls, url, NULL); }
После краулинга 1 миллиона URL средней длиной 80 байт хэш-таблица заняла следующий объём:
$ ./crawler_hashtable Memory usage: 128 MB Hash table: 96 MB (1M URLs × 80 bytes + overhead) Other: 32 MB Lookup time: 150 ns/lookup (including cache misses)
128 МБ на одно лишь отслеживание посещённых URL! На встраиваемом устройстве с 256 МБ ОЗУ это было неприемлемо.
Я реализовал фильтр Блума. Результаты получились такими:
$ ./crawler_bloom Memory usage: 18 MB Bloom filter: 1.2 MB (10 bits per URL) Other: 16.8 MB Lookup time: 45 ns/lookup False positive rate: 0.8% (8,000 false positives out of 1M) Memory reduction: 10.7× (128 MB → 12 MB) Speedup: 3.3× (150 ns → 45 ns)
В 10,7 раза меньше памяти и в 3,3 раза быстрее, при этом всего 0,8% ложноположительных результатов (то есть повторного краулинга нескольких страниц).
В этой главе мы поговорим о вероятностных структурах данных, которые позволяют в обмен на снижение точности сэкономить огромные объёмы памяти.
Что нам рассказывают в учебниках
Фильтр Блума — это эффективно использующая пространство вероятностная структура данных, проверяющая, находится ли элемент в множестве.
Свойства:
Отсутствие ложноотрицательных результатов: если он говорит «нет в множестве», то элемента точно нет
Возможные ложноположительные результаты: если он говорит «есть в множестве», то может ошибаться
Эффективное использование пространства: использует биты вместо хранения полных элементов
Высокая скорость: O(k), где k — количество хэш-функций (обычно 3-10)
Что нам говорят учебники: «Используйте фильтры Блума, когда вас устраивает наличие ложноположительных результатов и есть необходимость экономии памяти».
Проверка реальностью: как работают фильтры Блума
Базовая структура
Фильтр Блума — это битовый массив размера m и k хэш-функций:
typedef struct { uint64_t *bits; // Битовый массив size_t m; // Количество бит int k; // Количество хэш-функций } bloom_filter_t; bloom_filter_t *bloom_create(size_t m, int k) { bloom_filter_t *bf = malloc(sizeof(bloom_filter_t)); bf->m = m; bf->k = k; bf->bits = calloc((m + 63) / 64, sizeof(uint64_t)); return bf; }
Операция вставки
Хэшируем элемент k раз, задаём k бит:
void bloom_insert(bloom_filter_t *bf, const char *element) { for (int i = 0; i < bf->k; i++) { uint64_t hash = hash_function(element, i); size_t bit_pos = hash % bf->m; size_t word = bit_pos / 64; size_t bit = bit_pos % 64; bf->bits[word] |= (1UL << bit); } }
Операция поиска
Хэшируем элемент k раз, проверяем, все ли k бит заданы:
bool bloom_contains(bloom_filter_t *bf, const char *element) { for (int i = 0; i < bf->k; i++) { uint64_t hash = hash_function(element, i); size_t bit_pos = hash % bf->m; size_t word = bit_pos / 64; size_t bit = bit_pos % 64; if (!(bf->bits[word] & (1UL << bit))) { return false; // Точно нет в множестве } } return true; // Вероятно, находится в множестве (может быть ложноположительным результатом) }
Почему возникают ложноположительные результаты
После вставки большого количества элементов многие биты имеют значение 1. Поиск случайно может обнаружить, что заданы все k бит, даже если элемент не вставлялся.
Пример:
Вставка "foo": задаёт биты 5, 12, 23 Вставка "bar": задаёт биты 12, 18, 30 Вставка "baz": задаёт биты 5, 18, 42 Поиск "xyz": хэшируется в биты 5, 12, 18 Все три бита заданы (другими элементами)! Ложноположительный результат!
Выбор параметров: m и k
Частота ложноположительных результатов зависит от:
m: количества бит
k: количества хэш-функций
n: количества вставленных элементов
Оптимальное k: k = (m/n) × ln(2) ≈ 0,693 × (m/n)
Частота ложноположительных результатов: p ≈ (1 - e^(-kn/m))^k
Пример расчёта
Для 1 миллиона URL с 1% ложноположительных результатов:
Целевые показатели: p = 0,01, n = 1000000 Вычисляем m: m = -n × ln(p) / (ln(2))^2 m = -1000000 × ln(0,01) / 0,48 m ≈ 9585058 бит ≈ 1,2 МБ Оптимальное k: k = (m/n) × ln(2) k = 9,6 × 0,693 k ≈ 7 хэш-функций
То есть для 1 миллиона URL с 1% ложноположительных результатов нужно всего 1,2 МБ и 7 хэш-функций.
Сравните это с хэш-таблицей: 96 МБ (в 80 раз больше памяти!).
Реализация фильтра Блума с оптимальным использованием кэша
Наивная реализация обладает плохим поведением при работе с кэшем: k хэш-функций получают доступ к k произвольным адресам памяти.
Проблема: произвольный доступ к памяти
// Наивное решение: k произвольных операций доступа for (int i = 0; i < k; i++) { size_t bit_pos = hash(element, i) % m; // Каждый bit_pos случаен → промах кэша! }
При k=7 получается 7 промахов кэша на одну операцию поиска!
Решение: блочный фильтр Блума
Разбиваем битовый массив на блоки, используем по k бит в одном блоке:
#define BLOCK_SIZE 512 // 512 бит = 64 байт = 1 линия кэша typedef struct { uint64_t *bits; size_t num_blocks; int k; } blocked_bloom_t; bool blocked_bloom_contains(blocked_bloom_t *bf, const char *element) { uint64_t hash = hash_function(element, 0); size_t block = hash % bf->num_blocks; // Все k бит находятся в одном блоке (одна линия кэша!) uint64_t *block_ptr = &bf->bits[block * (BLOCK_SIZE / 64)]; for (int i = 0; i < bf->k; i++) { uint64_t h = hash_function(element, i); size_t bit_pos = h % BLOCK_SIZE; size_t word = bit_pos / 64; size_t bit = bit_pos % 64; if (!(block_ptr[word] & (1UL << bit))) { return false; } } return true; }
Почему это помогает:
Все k бит находятся в одной линии кэша
1 промах кэша вместо k промахов
Для k=7 промахов кэша становится в 7 раз меньше
Бенчмарк
Тест: 1 миллион операций поиска в фильтре Блума (k=7, m=10 миллионов бит) Наивный фильтр Блума: Промахи кэша: 7 миллионов (7 на одну операцию) Такты: 450 миллионов Время: 375 мс Блочный фильтр Блума (512-битные блоки): Промахи кэша: 1 миллион (1 на одну операцию) Такты: 85 миллионов Время: 71 мс Ускорение: 5,3×
Ускорение в 5,3 раза благодаря одному лишь повышению локальности кэша!
Продвинутый уровень: фильтр Блума с подсчётом
Стандартные фильтры Блума не могут удалять элементы (нельзя сбросить бит — он может быть общим с другими элементами).
В фильтрах Блума с подсчётом вместо битов используются счётчики:
typedef struct { uint8_t *counters; // 4-битные счётчики (0-15) size_t m; int k; } counting_bloom_t; void counting_bloom_insert(counting_bloom_t *bf, const char *element) { for (int i = 0; i < bf->k; i++) { size_t pos = hash(element, i) % bf->m; if (bf->counters[pos] < 15) { // Защита от переполнения bf->counters[pos]++; } } } void counting_bloom_delete(counting_bloom_t *bf, const char *element) { for (int i = 0; i < bf->k; i++) { size_t pos = hash(element, i) % bf->m; if (bf->counters[pos] > 0) { bf->counters[pos]--; } } }
Компромисс: использует в 4 раза больше памяти (4 бита на счётчик вместо 1 бита), но поддерживает удаление.
Пример из реального мира: безопасный браузинг Google Chrome
Перед отправкой URL на серверы Google браузер Chrome проверяет его на потенциальную зловредность при помощи фильтра Блума.
Проблема
Chrome необходимо сверить миллионы URL с чёрным списком зловредных сайтов:
В чёрном списке около миллиона элементов
Отправлять каждый URL в Google нельзя (конфиденциальность + задержки)
Ограниченная память на клиенте
Решение
Двухэтапная проверка:
Локальный фильтр Блума (быстрый, занимает мало памяти):
1 миллион элементов, 1% ложноположительных результатов
Память: 1,2 МБ
Поиск: <1 мкс
Если фильтр Блума сообщает «элемента нет в множестве» → безопасно, не обращаемся к серверу
Проверка на сервере (медленная и точная):
Если фильтр Блума сообщает «элемент может быть в множестве» → обращаемся на сервер за подтверждением
Проверки сервером требует всего 1% URL (ложноположительные результаты)
Результат:
99% URL проверяется локально (сетевые задержки отсутствуют)
1,2 МБ памяти (против 80 МБ для полной хэш-таблицы)
Сохранение конфиденциальности (на сервер отправляются только подозрительные URL)
Другие вероятностные структуры данных
1. Count-Min Sketch
Оценивает частоту элементов в потоке.
typedef struct { int **counters; // Массив счётчиков d × w int d; // Количество хэш-функций int w; // Ширина каждой строки } count_min_sketch_t; void cms_increment(count_min_sketch_t *cms, const char *element) { for (int i = 0; i < cms->d; i++) { int pos = hash(element, i) % cms->w; cms->counters[i][pos]++; } } int cms_estimate(count_min_sketch_t *cms, const char *element) { int min_count = INT_MAX; for (int i = 0; i < cms->d; i++) { int pos = hash(element, i) % cms->w; if (cms->counters[i][pos] < min_count) { min_count = cms->counters[i][pos]; } } return min_count; // Оценка (всегда ≥ истинного количества) }
Сценарий использования: анализ сетевого трафика (подсчёт частоты пакетов без хранения всех пакетов).
Память: O(d × w) вместо O(n) в случае точного подсчёта.
2. HyperLogLog
Оценивает кардинальность (количество уникальных элементов) в потоке.
typedef struct { uint8_t *registers; // m регистров int m; // Количество регистров (степень двойки) } hyperloglog_t; void hll_add(hyperloglog_t *hll, const char *element) { uint64_t hash = hash_function(element); int j = hash & (hll->m - 1); // Первые log2(m) бит int w = __builtin_clzll(hash >> __builtin_ctz(hll->m)) + 1; // Нули в начале if (w > hll->registers[j]) { hll->registers[j] = w; } } size_t hll_estimate(hyperloglog_t *hll) { double sum = 0; for (int i = 0; i < hll->m; i++) { sum += 1.0 / (1 << hll->registers[i]); } double alpha = 0.7213 / (1 + 1.079 / hll->m); // Коррекция перекоса return (size_t)(alpha * hll->m * hll->m / sum); }
Сценарий использования: подсчёт уникальных посетителей веб-сайта без хранения всех IP-адресов.
Память: 1,5 КБ в обмен на погрешность в 2% при миллиардах элементов!
Пример:
Точный подсчёт (хэш-таблица): 10 ГБ для 1 миллиарда уникальных IP-адресов HyperLogLog: 1,5 КБ с погрешностью в 2% Уменьшение объёма памяти: 6666667×
3. Кукушкин фильтр
Похож на фильтр Блума, но поддерживает удаление и имеет более высокую скорость поиска.
#define BUCKET_SIZE 4 typedef struct { uint8_t fingerprint[BUCKET_SIZE]; } bucket_t; typedef struct { bucket_t *buckets; size_t num_buckets; } cuckoo_filter_t; bool cuckoo_insert(cuckoo_filter_t *cf, const char *element) { uint64_t hash = hash_function(element); uint8_t fp = fingerprint(hash); // 8-битный фингерпринт size_t i1 = hash % cf->num_buckets; size_t i2 = (i1 ^ hash_function(&fp, 0)) % cf->num_buckets; // Пытаемся вставить в бакет i1 for (int j = 0; j < BUCKET_SIZE; j++) { if (cf->buckets[i1].fingerprint[j] == 0) { cf->buckets[i1].fingerprint[j] = fp; return true; } } // Пытаемся вставить в бакет i2 for (int j = 0; j < BUCKET_SIZE; j++) { if (cf->buckets[i2].fingerprint[j] == 0) { cf->buckets[i2].fingerprint[j] = fp; return true; } } // Оба бакета заполнены, нужен перенос (кукушкино хэширование) // ... (сложная логика переноса) return false; // Фильтр заполнен } bool cuckoo_contains(cuckoo_filter_t *cf, const char *element) { uint64_t hash = hash_function(element); uint8_t fp = fingerprint(hash); size_t i1 = hash % cf->num_buckets; size_t i2 = (i1 ^ hash_function(&fp, 0)) % cf->num_buckets; // Проверяем бакет i1 for (int j = 0; j < BUCKET_SIZE; j++) { if (cf->buckets[i1].fingerprint[j] == fp) { return true; } } // Проверяем бакет i2 for (int j = 0; j < BUCKET_SIZE; j++) { if (cf->buckets[i2].fingerprint[j] == fp) { return true; } } return false; }
Преимущества перед фильтром Блума:
Поддерживает удаление
Улучшенная локальность кэша (всего два проверяемых бакета против k случайных позиций)
Чуть более оптимальное использование пространства
Бенчмарк:
Тест: 1 миллион элементов, 1% ложноположительных результатов Фильтр Блума: Память: 1,2 МБ Поиск: 7 промахов кэша Время: 150 нс Кукушкин фильтр: Память: 1,1 МБ (чуть лучше) Поиск: 2 промаха кэша (2 бакета) Время: 65 нс Ускорение: 2,3×
Когда использовать вероятностные структуры данных
Реализовав множество разных вероятностных структур данных, я понял, когда их стоит применять.
Фильтры Блума следует использовать, когда:
Ограничена память: экономия памяти в 10-100 раз по сравнению с хэш-таблицами
Приемлемы ложноположительные результаты: допустимы возникающие время от времени ошибки
Часты негативные запросы: «посещён ли этот URL», когда большинство URL новые
Пример: устранение дубликатов URL в веб-краулере
1 миллион URL: 1,2 МБ (фильтр Блума) против 96 МБ (хэш-таблица)
0,8% ложноположительных результатов → небольшое количество страниц придётся краулить дважды (приемлемо)
Count-Min Sketch следует использовать при:
Подсчёте частотности в потоках: точное количество не требуется
Ограниченной памяти: все элементы хранить невозможно
Пример: анализ сетевого трафика
Подсчёт типов пакетов без хранения всех пакетов
100 КБ (sketch) против 10 ГБ (точный подсчёт)
HyperLogLog следует использовать при:
Определении кардинальности: «Сколько есть уникальных пользователей?»
Миллиардах элементов: подсчитывать точно непрактично
Пример: аналитика веб-сайта
1,5 КБ в обмен на погрешность в 2% для 1 миллиарда уникальных IP-адресов
Против 10 ГБ в случае точного подсчёта
Кукушкин фильтр следует использовать, когда:
Требуется удаление: фильтры Блума не могут удалять
Требуется повышенная производительность поиска: 2 промаха кэша против 7
Пример: политика занесения в кэш
Отслеживаем недавно встреченные элементы
Удаляем старые элементы, когда они очищаются из кэша
НЕ используйте вероятностные структуры данных, когда:
Неприемлемы ложноположительные результаты: решения критичны для безопасности
Память избыточна: просто выберите хэш-таблицу
Нужны точные ответы: вероятностный = неточный
Соединяем всё вместе: оптимизированный веб-краулер
Вот окончательный оптимизированный краулер, в котором используется блочный фильтр Блума:
#define BLOCK_SIZE 512 #define BITS_PER_URL 10 #define NUM_HASH 7 typedef struct { uint64_t *bits; size_t num_blocks; int k; } crawler_bloom_t; crawler_bloom_t *crawler_bloom_create(size_t max_urls) { crawler_bloom_t *bf = malloc(sizeof(crawler_bloom_t)); size_t total_bits = max_urls * BITS_PER_URL; bf->num_blocks = (total_bits + BLOCK_SIZE - 1) / BLOCK_SIZE; bf->bits = calloc(bf->num_blocks * (BLOCK_SIZE / 64), sizeof(uint64_t)); bf->k = NUM_HASH; return bf; } bool crawler_is_visited(crawler_bloom_t *bf, const char *url) { uint64_t hash = hash_function(url, 0); size_t block = hash % bf->num_blocks; uint64_t *block_ptr = &bf->bits[block * (BLOCK_SIZE / 64)]; for (int i = 0; i < bf->k; i++) { uint64_t h = hash_function(url, i); size_t bit_pos = h % BLOCK_SIZE; size_t word = bit_pos / 64; size_t bit = bit_pos % 64; if (!(block_ptr[word] & (1UL << bit))) { return false; // Точно не посещён } } return true; // Вероятно посещён (может быть ложноположительным результатом) } void crawler_mark_visited(crawler_bloom_t *bf, const char *url) { uint64_t hash = hash_function(url, 0); size_t block = hash % bf->num_blocks; uint64_t *block_ptr = &bf->bits[block * (BLOCK_SIZE / 64)]; for (int i = 0; i < bf->k; i++) { uint64_t h = hash_function(url, i); size_t bit_pos = h % BLOCK_SIZE; size_t word = bit_pos / 64; size_t bit = bit_pos % 64; block_ptr[word] |= (1UL << bit); } }
Окончательный бенчмарк
Тест: краулинг 1 миллиона URL (средняя длина: 80 байт) Хэш-таблица: Память: 128 МБ Поиск: 150 нс (с промахами кэша) Ложноположительные результаты: 0% Наивный фильтр Блума: Память: 1,2 МБ (в 107 раз меньше) Поиск: 375 нс (7 промахов кэша) Ложноположительные результаты: 0,8% Блочный фильтр Блума: Память: 1,2 МБ (в 107 раз меньше) Поиск: 45 нс (1 промах кэша) Ложноположительные результаты: 0,8% Ускорение: 3,3× (150 нс → 45 нс) Снижение объёма занимаемой памяти: 107×
Подведём итог
Кризис памяти был устранён. Объём потребляемой веб-краулером памяти снизился с 128 МБ до 1,2 МБ (в 107 раз). Время поиска уменьшилось с 150 нс до 45 нс (в 3,3 раза быстрее) со всего 0,8% ложноположительных результатов. Возникающая время от времени необходимость повторного краулинга — это маля цена за освобождение половины ОЗУ устройства.
Ключевые выводы:
Фильтры Блума позволяют обменять ухудшение точности на снижение объёма памяти. Экономия памяти в 10-100 раз с <1% частотой ложноположительных результатов. Идеально для запросов «встречал ли я это раньше?».
Блочные фильтры Блума удобны для кэша. Размещение всех k бит в одной линии кэша снижает количество промахов кэша с k до 1. В случае k=7 скорость увеличивается в 5,3 раза.
Важны оптимальные параметры. Для получения целевой частоты ложноположительных результатов p используйте k = 0.693 × (m/n) хэш-функций и m = -n × ln(p) / (ln(2))² бит.
Кукушкины фильтры лучше справляются с поиском, чем фильтры Блума. Только 2 промаха кэша против k промахов кэша. В 2,3 раза быстрее при сравнимом объёме памяти.
HyperLogLog — волшебное решение при определении кардинальности. Проверка миллиардов уникальных элементов с 1,5 КБ и погрешностью 2%. Экономия памяти в 6 миллионов раз по сравнению с точным подсчётом.
Показатели веб-краулера:
Блочный фильтр Блума: в 107 раз меньше памяти, чем при хэш-таблице
Поиск: быстрее в 3,3 раза (45 нс против 150 нс)
Ложноположительные результаты: 0,8% (8000 из 1 миллиона)
Промахи кэша: меньше в 7 раз (1 против 7 на одну операцию поиска)
Вероятностные структуры данных — мощный инструмент в случае допустимости небольших погрешностей. Они позволяют создавать приложения, которые были бы невозможны при использовании точных структур данных.
В следующей главе: мы исследуем реальные случаи применения этих техник к загрузчикам.
