Dictionary Encoding: кросс-форматный анализ
В Модуле 01 мы рассмотрели идею dictionary encoding: замена повторяющихся значений на индексы словаря. В Модуле 02 — как Parquet реализует RLE_DICTIONARY. В Модуле 03 — как ORC использует отсортированный словарь с DICTIONARY_DATA stream.
Теперь вопрос другой: почему эти реализации настолько различны? Один алгоритм — и пять принципиально разных архитектурных решений в Parquet, ORC, Avro, Arrow и DuckDB. Каждое оптимизировано под свой паттерн доступа, и каждое ломается на своих граничных условиях.
Анатомия решений: что выбирает каждый формат
Parquet: per-page dictionary с атомарным fallback
Parquet принимает решение о dictionary на уровне column chunk (все data pages колонки в одном row group). Процесс:
Writer начинает column chunk
Writer начинает записывать column chunk. Буфер dictionary page накапливает уникальные значения по мере записи data pages.Для каждого значения: lookup в словаре → index или insert
Каждое новое значение: если уже в словаре — записываем индекс. Если новое — добавляем в словарь. Словарь растёт по мере записи.Словарь < 1 MB → Dictionary Page + Data Pages
Словарь поместился в лимит. Записывается dictionary page + data pages с RLE-encoded индексами. Одна dictionary page на весь column chunk.Словарь > 1 MB → Fallback на PLAIN
Словарь превысил лимит. Writer отбрасывает весь словарь и перезаписывает ВСЕ data pages в PLAIN. Это происходит в памяти до записи на диск.Критический момент: fallback атомарный. Нельзя часть pages закодировать dictionary, а часть — PLAIN. Весь column chunk использует одну кодировку. Это упрощает reader (не нужно проверять кодировку каждой page), но тратит ресурсы writer на колонках, где dictionary неизбежно провалится.
Порог dictionary_page_size_limit (по умолчанию 1 MB в PyArrow) — это размер словаря в байтах, не количество уникальных значений. Для коротких строк ("US", "DE") — до ~500K уникальных значений в 1 MB. Для длинных строк ("United States of America") — ~40K. Реальный порог кардинальности зависит от средней длины значений.
ORC: per-stripe sorted dictionary
ORC принимает решение о dictionary per-stripe (~256 MB данных). Ключевое отличие — словарь отсортирован:
Sorted Dictionary
Range Predicate Pushdown
WHERE fruit BETWEEN ‘b’ AND ‘d’
SQL запрос с range-предикатом на строковой колонке с dictionary encoding.Binary search: ‘b’ → idx 1, ‘d’ → idx 3
Binary search в отсортированном словаре: 'b' → index 1 (banana), 'd' → index 3 (date). Предикат: index BETWEEN 1 AND 3. Не нужно декодировать строки.Фильтр: idx BETWEEN 1 AND 3
Фильтрация по числовым индексам вместо строковых сравнений. На 1M строк: 1M integer comparisons вместо 1M string comparisons. ~10x ускорение.Преимущество ORC: range-predicate pushdown через отсортированный словарь. WHERE name > 'M' превращается в WHERE index > 12 — integer comparison вместо string comparison. Parquet не может этого делать, потому что его словарь не отсортирован.
Недостаток: сортировка словаря при записи стоит O(n log n). Для stripe с 500K уникальных строк — это ощутимо. Но выигрыш при чтении (range queries) обычно перевешивает.
Arrow: DictionaryArray как тип
В Arrow dictionary — не кодировка, а тип данных. DictionaryArray — отдельный тип массива наравне с Int64Array или StringArray:
Структура в памяти
В IPC (Arrow Flight / Feather)
Msg 1: Dictionary Batch (полный словарь, id=0)
Первый message содержит dictionary batch с полным словарём и dictionary id. Последующие record batch-и содержат только индексы, ссылаясь на этот id.Msg 2..N: Record Batch (indices only, dict_id=0)
Record batch содержит только Int32 indices. Reader использует ранее полученный словарь для декодирования. Экономия на передаче: словарь один раз, индексы — для каждого batch.Опционально: Delta Dictionary (новые записи)
Delta dictionary: если в новом batch появились значения, не входящие в словарь — отправляется DELTA dictionary message с новыми записями. Не нужно пересылать весь словарь.Критическое отличие Arrow: нет fallback. Если создали DictionaryArray — он навсегда dictionary. Конвертация обратно в plain — явная операция (dictionary_decode()). Это design choice: Arrow — in-memory формат, где тип определяется при создании, а не при записи.
В Arrow IPC формат поддерживает delta dictionaries — инкрементальные обновления словаря. Если в потоке record batch-ей появляется новое значение — отправляется delta dictionary message, добавляющий запись. Не нужно пересылать весь словарь.
DuckDB: analyze-and-pick
DuckDB не привязан к одному формату хранения. Его Analyzer сэмплирует данные и выбирает оптимальную кодировку для каждого сегмента (~122K строк):
Сегмент колонки (~122K строк)
DuckDB разбивает колонку на сегменты по ~122,880 строк (row group size). Для каждого сегмента Analyzer выбирает кодировку независимо.Подсчёт статистик: cardinality, min, max, avg_length
Analyzer считает уникальные значения, min/max, распределение. Для строк: средняя длина, кардинальность. Для чисел: range, delta patterns.Ключевое отличие DuckDB: нет жёсткого fallback — есть континуум вариантов. Если dictionary не подходит — Analyzer рассмотрит FSST, BitPacking, FOR, Constant, RLE или Uncompressed. Каждый сегмент может использовать свою кодировку. Это даёт гибкость, которой нет у Parquet (dictionary-or-PLAIN) и ORC (dictionary-or-direct).
Кардинальность: когда dictionary ломается
Главный враг dictionary encoding — высокая кардинальность. Вопрос: при какой кардинальности словарь становится убыточным?
Практическое правило: dictionary выгоден, когда кардинальность ≪ N (количества строк). Точка безубыточности зависит от средней длины значений:
- Короткие строки (
"US","DE") — dictionary выгоден до ~100K уникальных - Средние строки (
"Engineering") — до ~10K–50K уникальных - Длинные строки (
"https://...") — до ~1K–5K уникальных
Гибридные стратегии и fallback-деревья
В реальности решение «dictionary или нет» — не бинарное. Форматы используют деревья решений:
Parquet Writer
Начать с Dictionary
Writer начинает с dictionary encoding для каждой колонки — это default. Пессимистичный подход: пробуем оптимальное, fallback на простое.dict_size > limit?
Проверка при записи каждой page: если dictionary page > dictionary_page_size_limit — fallback. Порог настраивается (PyArrow: dictionary_pagesize_limit).RLE_DICTIONARY для column chunk
Dictionary encoding для всего column chunk. Dictionary Page записывается первой, затем Data Pages с RLE-encoded индексами.При overflow → весь chunk → PLAIN
DuckDB Analyzer
Analyze сегмент
Analyzer сэмплирует сегмент: подсчёт уникальных, min/max, распределение, средняя длина. Решение per-segment, не per-column.card=1 → Constant / card<thresh → Dict / else → FSST/BP
Если все значения одинаковы — Constant (0 бит). Если кардинальность ≤ порога — Dictionary. Иначе — для строк FSST, для чисел BitPacking/FOR.Кодировка per-segment, может меняться
Каждый сегмент может использовать разную кодировку. Колонка city: первые 122K строк = Dictionary (10 городов), следующие = FSST (1000 городов).fallback — континуум вариантов
Dictionary overhead: что стоит словарь
Словарь — не бесплатная абстракция. Каждый формат платит свою цену:
Ключевые выводы
- Пять форматов — пять архитектур dictionary. Parquet: per-page, insertion order, атомарный fallback. ORC: per-stripe, sorted, range-predicate pushdown. Arrow: per-array, тип (не кодировка), delta dictionaries. DuckDB: per-segment, frequency-sorted, multi-variant fallback.
- Сортировка словаря — трейдофф. ORC: sorted → range predicates за O(log n), но O(n log n) при записи. Parquet: insertion order → нет range pushdown, но O(1) insert.
- Fallback-стратегии определяют поведение на граничных данных. Parquet ломается атомарно (весь chunk → PLAIN). DuckDB плавно переключается (Dictionary → FSST → Uncompressed).
- Кардинальность — ключевой порог: до ~10K–50K уникальных значений (зависит от длины строк) dictionary выгоден. Выше — словарь занимает больше, чем экономит.
- Arrow DictionaryArray — тип, не кодировка. Нет runtime fallback — решение принято при создании. Delta dictionaries поддерживают инкрементальные обновления в streaming-сценариях.
- Dictionary overhead реален: 5–8 MB RAM per column при записи, дополнительный I/O при чтении, indirect memory access при decode. На колонках с 1000+ столбцов — суммируется.