Сериализованные справочники: работа без десериализации

Справочники, или словари — обычно большие объёмы статических данных, адресуемые и не модифицируемые при работе программы. Как правило, подготавливаются или загодя, при разработке, или вне программы, или в специальных её режимах. Зачастую с ними обращаются как с обычными структурами, однако можно организовывать их и иначе — так, чтобы работа с ними шла вообще без резервирования памяти и каких‑либо лишних операций, а в памяти они занимали минимально возможный объём.


Я Андрей Коваленко, или Keva. Сделал поисковые движки Рамблера и украинской Меты. Последние годы работал с компанией МойОфис, где разработал корпоративный поиск.

Продолжу разговор о строительстве поисковых машин заметкой про эффективность организации так называемых словарей или справочников — статических данных key:value с минимальным временем доступа и сжатыми требованиями к ресурсам.

Впервые я реализовал такой справочник в 1995 году при разработке словарной системы, где предварительно размеченный словарь был преобразован в объектный код и представлен динамической библиотекой windows (.dll) — это был проект «Русский Филолог», второй мой коробочный продукт после Прописи.

Основные достоинства статического представления словарей я уже перечислил выше: минимум объёма, отсутствие операций резервирования памяти при обращении к ним, отсутствие какой‑либо инициализации. Давайте теперь по шагам построим такой словарь, но так, чтобы ещё и обращение к нему занимало минимум времени.

Пусть у нас есть простая сортированная таблица, где персонажам — именам поставлены в соответствие идентификаторы, 525 штук:

Персонаж

Номер

Abel

1

Abraham

2

Ada

3

...

Winnie

525

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

Для наглядности запишем эту таблицу в виде инициализированной структуры:

const struct Person
{
  const char* name;
  int         id;
} persons[] =
{
  { "Abel", 1 },
  { "Abraham", 2 },
  { "Ada", 3 },
  ...
  { "Winnie", 525 }
};

В таком виде, зная, что данные отсортированы по первому полю 'name', можно искать по ним двоичным поиском, выполняя log₂N сравнений строки со строкой. И давайте сразу ещё и сериализуем этот массив так, чтобы можно было искать по его дампу тем же алгоритмом. Для этого добавим к этому массиву в сериализованной форме индекс с фиксированным размером элемента (uint16). Для этого определим методы его сериализации:

template <size_t N>
size_t  GetBufLen( const Person (&names)[N] )
...

template <class O, size_t N>
O*  Serialize( O* o, const Person (&names)[N] )
...

Полный код сериализации, примеров и измерений лежит на github.

Результатом будет blob, где в первых двух байтах лежит количество элементов (525), далее таблица из 525 16-битных смещений реальных записей (младший байт + старший байт), после чего собственно записи переменной длины (asciiz‑строка и компактно сериализованный id). Результирующий размер — 5525 байт. Случайно так получилось, честно!

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

Метод

ms на 106 поисков

std::find_if() — оптимизированный поиск прямым перебором слева направо

~510 ± 20

std::lower_bound() — двоичный поиск в массиве средствами стандартной библиотеки

85 ± 5

Рукописный двоичный поиск в сортированном массиве — классическая дихотомия до схождения границ или найденного элемента

70 ± 5

Рукописный двоичный поиск в дампе сортированного массива

71 ± 5

std::lower_bound() по очевидным причинам в несколько раз быстрее, чем std::find_if() - это двоичный поиск против прямого просмотра, пусть и реализованный [в библиотеке gcc] пачками по 4 элемента.

Рукописный двоичный поиск также заметно быстрее шаблонного метода из стандартной библиотеки - но это тоже понятно: std::lower_bound() оперирует методом сравнения is_less(...), что ведёт к лишним вызовам функции сравнения строк (нужен, как минимум, финальный вызов, чтобы понять, совпадает ли ключ найденной границы с искомым) , тогда как в рукопиской реализации сравнение делается однократно, а его результат (<=>) используется для принятия решения.

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

В следующей заметке построим более эффективное представление такого словаря в виде базисного дерева (radix tree, patricia), тоже сериализуем и оценим скорость поиска.

@Keva
10.03.2025 19:04 UTC
Первоисточник

Комментарии

@dersoverflow
10.03.2025 16:41 UTC
0

лучше сразу смотрите на Хеш-таблицу с транзакциями https://ders.by/cpp/memdb/memdb.html

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

  2. таким образом, копирование всей таблицы - один вызов memcpy()! а дальше пишите в файл, передавайте по шлангу... все что угодно ;)

  3. ну и, естественно, O(1) для поиска элементов. а у дерева логарифм.

@Keva
10.03.2025 17:05 UTC
0

1 и 2 справедливы для обоих подходов - и для упомянутой вами хэш-таблицы,и для сериализованного базисного дерева.

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

Хэш-таблица, демонстрирующая o(1), будет к тому же, вероятно, очень разреженной, а значит, "жирной" по размеру.

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

11.03.2025 18:08 UTC
0

построить по ней итератор, перебирающий ключи в порядке возрастания (убывания) сложно и дорого

ой ли! создаете ключ с нужным вам порядком элементов и пишете таблицу на диск.

а если надо, то и несколько разных "порядков".

будет к тому же, вероятно, очень разреженной, а значит, "жирной" по размеру

с чего бы?

назовите мне средний размер ключа/значения, и я вам скажу размер.

11.03.2025 18:14 UTC
0

Размер ключа у полнотекстовой поисковой машины невелик: от 3-4 до 20-25 байт. В зависимости от того, словарная это лексема или неизвестная.

А как вы хотите на hash-таблице сделать итератор по ключам? Например, для реализации поиска с усечением - сло*?

12.03.2025 22:35 UTC
0

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

const int n=1000, ksz=14, vsz=10;
int data[50];

auto bm=new_blob_map(mp, n, false, mem_hash, mem_equal);
for (int i=0; i<n; i++) {
    data[0]=i;
    bm->insert({data, ksz}, {data, vsz});
}

out.ex_write(tx_buf(mp)+"used "+bm->info().used+"\n");

в итоге:

  1. n=1000, ksz=14, vsz=10 получаем used 53808

  2. n=1000, ksz=14, vsz=100 получаем used 149808

как вы хотите на hash-таблице сделать итератор по ключам?

нам же достаточно отсортированного массива слов?

значит перед записью таблицы на диск мы создаем себе ключ с отсортированным массивом "указателей" на слова. все! загружаем где угодно и сразу же пользуемся.

13.03.2025 09:00 UTC
0

Да, разумно.

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

Типичный размер словаря ключей немного поработавшей перимущественно русскояычной полнотекстовой машины - за миллион (~60,000 русской общей лексики из активного запаса, два по столько орфографических ошибок, формально годящихся в русские слова, остальное - числа и иероглифы вроде rs232).

@BugM
10.03.2025 16:59 UTC
0

Буханка_Троллейбус.jpg

Мало данных - кладите все в мапу.

Много данных - используйте свою любимую БД. Возможно с кешем перед ней.

@Keva
10.03.2025 17:41 UTC
0

"Любимые БД" сильно просаживает производительность поисковых движков.

Требуют много памяти и медленно работают.

А я веду именно туда.

10.03.2025 17:44 UTC
0

Вы много поисковых движков уже написали?

Или хотя бы поучаствовали в написании и эксплуатации хотя бы в течении нескольких лет в роли сеньора и выше?

10.03.2025 17:46 UTC
+1

Из больших - Апорт!, Рамблер от 2000 года и дальше, украинскую Мету.

Эти три спроектировал, в значительной мере написал, последние два потом развивал и эксплуатировал.

И несколько поменьше.

А вы?

10.03.2025 17:50 UTC
0

В районе российского бигтеха руку приложил. И да там все мигрируется в newsql бд постепенно. Вот такие поделия как у вас выводятся. Специализированные решения на голову лучше и работают быстрее и масштабируются лучше.

В мое сообщение выше можно добавить:

Очень много данных - берите newsql.

10.03.2025 17:54 UTC
+1

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

10.03.2025 18:06 UTC
0

Держите целый доклад. Делал этот доклад не я, но не суть. Он хорошо отражает современное состояние технологий.

10.03.2025 18:17 UTC
0

Непременно прочту.

Так всё-таки к разработке каких известных полнотекстовых поисковых движков вы имели отношение? Или хотя бы к эксплуатации?

---

Пардон, заглянул - там Apache Lucerne, разработка двадцатилетней давности, довольно громоздкая и с низким качеством поиска (отношение правильно найденных к общему количеству найденных), на сегодня - прошлый век.

Видимо, проделана большая работа над тем, чтобы оно таки шевелилось.

Терешке, думаю, тяжело пришлось обосновывать TCO.

@qw1
10.05.2025 12:37 UTC
0

Решение не production-ready, т.е. для другой структуры данных нужно будет писать новый код.
Для другой операции над данными (поиск по 2 словам, например) - тоже писать код.

Похожий подход в общем виде реализован в БД полнотекстового поиска (Sphinx, Lucene).
Там либо индексный файл, либо подход "читаю блоб 1 раз от начала до конца, для скорости прыгая через блоки, используя скип-листы). И там куча операций реализована, куча эвристик, чтобы сжать данные. Например, словарь сортируется, а соседние слова пишутся только с разницей в суффиксе.

@Keva
29.01.2026 10:36 UTC
0

Это нормально.

diff-компрессия массивов вхождений, "вынесение за скобки" идентификатора формы слова и кодирование по статическим таблицам по Хаффману для высокочастотных слов (и, в, пунктуация) - применяется буквально во всех машинках.

Обычно всё-таки И индексный файл (по ключу адресовать blob), И последовательное чтение этих упорядоченых blob-ов c "пересечением слиянием".