Перейти к содержанию
Learning Platform
Глоссарий Troubleshooting
Урок 10.01 · 35 мин
Продвинутый
LZ77Huffman CodingANSZstd InternalsLZ4 InternalsSnappy InternalsCompression Algorithms

Внутренности алгоритмов компрессии: LZ77, Huffman, ANS

В Модуле 01 мы сравнили четыре алгоритма компрессии — Snappy, LZ4, Zstd, GZIP — по скорости и степени сжатия. Мы узнали какой выбрать, но не почему они так отличаются.

Этот урок — внутрь алгоритмов. Мы разберём три фундаментальных техники, из которых строятся все четыре алгоритма: LZ77 (поиск повторов), Huffman coding (частотные коды), и ANS (инновация Zstd). Понимание внутренностей объясняет, почему Zstd настраивается на 22 уровнях, а Snappy — нет, и почему LZ4 быстрее Snappy при похожем подходе.

LZ77: скользящее окно и поиск повторов

LZ77 (Lempel–Ziv, 1977) — фундаментальная идея, которую используют все четыре алгоритма. Принцип: если последовательность байтов уже встречалась раньше в потоке — вместо повторной записи укажи ссылку (offset + length) на предыдущее вхождение.

LZ77: скользящее окно — пошаговый пример

Вход: A B C D A B C X A B C D A B C Y

Входная строка для компрессии. LZ77 обрабатывает слева направо, поддерживая скользящее окно уже обработанных данных.
Шаг 1: первые 4 символа — ещё нет совпадений
Окно: [пусто] → Выход: литералыНачало: скользящее окно пустое. Символы A, B, C, D записываются как литералы — по 1 байту каждый. Пока ничего не сэкономлено.
Шаг 2: позиция 4 — нашли совпадение!
Окно: [A B C D] → Match!Позиция 4: текущий символ A. Смотрим в окно — A есть на позиции 0. Проверяем длину совпадения: A B C = 3 символа (X не совпадает с D). Записываем ссылку: offset=4 (расстояние назад), length=3.
Шаг 3: X — литерал, нет совпадения
Окно: [A B C D A B C] → литерал XСимвол X встречается впервые — записывается как литерал. Окно расширилось: теперь содержит все предыдущие символы.
Шаг 4: позиция 8 — длинное совпадение
Окно: [A B C D A B C X] → Long match!Позиция 8: символ A. Совпадение с позицией 0: A B C D A B C = 7 символов (Y не совпадает с X). Ссылка: (offset=8, length=7). 2 байта вместо 7 — экономия 5 байт!

Итог: [A][B][C][D] + (4,3) + [X] + (8,7) + [Y] = ~12 байт вместо 16

Итог: 16 символов (16 байт) → 4 литерала + 2 ссылки + 1 литерал + 1 ссылка = ~12 байт. Экономия ~25%. На реальных данных с длинными повторами экономия значительно больше.

Три параметра определяют эффективность LZ77:

  • Размер окна — сколько байт назад алгоритм ищет совпадения. Больше окно → больше шанс найти длинный match → лучше сжатие. Но: больше памяти и CPU на поиск.
  • Минимальная длина match — обычно 3–4 байта. Ссылка (offset + length) сама занимает 2–3 байта — если совпадение короче, ссылка дороже литералов.
  • Алгоритм поиска — как быстро находить совпадения в окне. Наивный O(n×w) — слишком медленно. Hash table — O(1) lookup.
NOTE

Все четыре алгоритма (Snappy, LZ4, Zstd, GZIP) используют LZ77 как первую фазу. Разница — в размере окна, алгоритме поиска, и в том, что происходит после: Snappy и LZ4 на этом останавливаются, а Zstd и GZIP добавляют вторую фазу — энтропийное кодирование.

Huffman Coding: частотные коды

Huffman coding (1952) — вторая фундаментальная техника. Идея: символы с высокой частотой кодируются короткими битовыми последовательностями, редкие — длинными. Условие: никакой код не является префиксом другого (prefix-free code).

Дерево Хаффмана: пример построения

Частоты: A=45 B=25 C=15 D=10 E=5 (100 символов)

Входные данные: строка из 100 символов. Частоты: A — 45 раз, B — 25, C — 15, D — 10, E — 5. Фиксированный код (3 бит/символ) = 300 бит. Хаффман может лучше.
Шаг 1: создать leaf nodes, отсортировать
E: 5Самый редкий символ — E с частотой 5. Получит самый длинный код.
D: 10D с частотой 10. Второй по редкости.
C: 15C с частотой 15.
B: 25B с частотой 25.
A: 45Самый частый символ — A с частотой 45. Получит самый короткий код.
Шаг 2: объединить два минимальных (E+D=15)
[E+D]: 15Объединяем E(5) и D(10) в узел с суммарной частотой 15. E → ветка 0, D → ветка 1 (или наоборот). Внутренний узел возвращается в очередь.
C: 15C остаётся. Теперь два элемента с частотой 15 — они следующие кандидаты.
B: 25B ждёт своей очереди.
A: 45A — самый частый, объединится последним.
Шаг 3–5: продолжить объединение до корня
Финальное дерево → кодыКаждый путь от корня к листу = код символа. Левая ветка = 0, правая = 1. Частые символы — ближе к корню (короче код).

Итого: 200 бит вместо 300 (фиксированный 3 бита/символ). Экономия 33%

A: 45×1 = 45 бит. B: 25×2 = 50. C: 15×3 = 45. D: 10×4 = 40. E: 5×4 = 20. Итого: 200 бит vs 300 при фиксированном коде. Экономия 33%.

Huffman в GZIP и DEFLATE

GZIP использует DEFLATE — комбинацию LZ77 + Huffman. Сначала LZ77 находит повторы и генерирует поток символов (литералы + ссылки offset/length). Затем Huffman кодирует этот поток:

DEFLATE = LZ77 + Huffman: двухфазный pipeline

Raw данные

Сырые данные. Могут быть любого типа — после column encoding это уже сжатые байты.

LZ77 (окно 32 KB)

Фаза 1: LZ77. Скользящее окно 32 KB. Находит повторяющиеся последовательности, заменяет на (offset, length) ссылки. Выход: смесь литералов и ссылок.
литералы + (offset, length)

Huffman coding

Фаза 2: Huffman coding. Подсчитать частоты литералов и length-символов в выходе LZ77. Построить дерево Хаффмана. Закодировать каждый символ переменным числом бит.

Сжатый поток + Huffman table

Сжатый выход: Huffman-таблица (описание дерева) + закодированный поток бит. Декодер читает таблицу, восстанавливает дерево, декодирует поток побитово.

Huffman — оптимальный prefix code: каждый символ кодируется целым числом бит. Символ с вероятностью 0.33 получает 2 бита (оптимально: log₂(3) ≈ 1.58 бит). Потеря: до 1 бита на символ. Для алфавита из 256 символов это мало, но для потока из 3–4 уникальных значений (типично после LZ77) — заметная неэффективность.

ANS: инновация Zstd

ANS (Asymmetric Numeral Systems, Jarek Duda, 2007–2014) — инновация, которая сделала Zstd возможным. Ключевое преимущество: ANS кодирует символы дробным числом бит, устраняя потерю “целых бит” Хаффмана.

Huffman vs ANS: целые биты vs дробные
СимволСимвол из алфавита после LZ77
ЧастотаВероятность символа в потоке
Huffman (бит)Huffman: каждый символ = целое число бит. Округление вверх от -log₂(P).
ANS (бит)ANS: дробное число бит. Точно -log₂(P). Нет потери на округление.
A (литерал)Самый частый символ — литерал A
0.70Вероятность 70%
1 битHuffman: -log₂(0.70) = 0.51 → округление вверх = 1 бит. Потеря: 0.49 бит (96% overhead!)
0.51 битANS: точно -log₂(0.70) = 0.51 бит. Нет потери. Экономия: 0.49 бит × 70% потока = значительно.
B (ссылка)Ссылка на повтор — второй по частоте символ
0.20Вероятность 20%
3 битаHuffman: -log₂(0.20) = 2.32 → округление вверх = 3 бита. Потеря: 0.68 бит.
2.32 битANS: точно 2.32 бит. На 20% потока = ощутимая экономия.
C (редкий)Редкий символ — мало потерь при любом методе
0.10Вероятность 10%
4 битаHuffman: -log₂(0.10) = 3.32 → 4 бита. Потеря: 0.68 бит. Но символ редкий — потеря на поток невелика.
3.32 битANS: 3.32 бит. Для редких символов разница с Huffman меньше.
СреднееСредневзвешенная длина кода
1.70 бит/символHuffman средняя: 0.70×1 + 0.20×3 + 0.10×4 = 1.70 бит. Энтропия: 1.16 бит. Overhead: 47%.
1.16 бит/символANS средняя: 0.70×0.51 + 0.20×2.32 + 0.10×3.32 = 1.16 бит. Точно совпадает с энтропией.

Как ANS достигает дробных бит

Ключевая идея ANS: вместо кодирования каждого символа отдельно, ANS кумулятивно кодирует поток символов в одно большое число (state). Каждый новый символ трансформирует state по формуле, которая добавляет ровно -log₂(P) бит информации:

ANS: кодирование через трансформацию state

state = начальное значение

Начальное состояние: state = 0 (или фиксированное начальное значение). Символы обрабатываются последовательно — каждый символ изменяет state.
Символ A (P=0.70)
state → state' = encode(state, A)Символ A с вероятностью 0.70 добавляет -log₂(0.70) ≈ 0.51 бит к state. State растёт незначительно. Формула: state' = floor(state / freq_A) × total + cumfreq_A + (state mod freq_A).
Символ A (P=0.70)
state → state'' = encode(state', A)Ещё один символ A — state растёт ещё на 0.51 бит. Суммарно: 1.02 бита на 2 символа. Huffman потратил бы 2 бита (1 бит × 2).
Символ B (P=0.20)
state → state''' = encode(state'', B)Символ B с вероятностью 0.20 добавляет -log₂(0.20) ≈ 2.32 бит. State делает больший скачок. Суммарно: ≈3.34 бита на 3 символа.

Финальный state = все символы, закодированные в одно число

В конце потока: state содержит всю информацию обо всех символах. Записывается как одно число. Декодер читает state и извлекает символы в обратном порядке (LIFO). Практически: state периодически сбрасывается в выходной буфер, когда превышает порог.
TIP

ANS — это теоретически оптимальный энтропийный кодер: средняя длина кода точно совпадает с энтропией Шеннона (при достаточной точности таблиц). Huffman всегда тратит как минимум 1 бит на символ — даже если символ несёт 0.01 бит информации. Для потоков с сильно неравномерным распределением (типично после LZ77) разница между ANS и Huffman может составлять 10–30% размера выходного файла.

Zstd: LZ77 + ANS + адаптивный поиск

Теперь можно понять, почему Zstd настраивается на 22 уровнях, а Snappy — нет. Zstd — это pipeline из трёх компонентов, и каждый имеет настраиваемые параметры:

Zstd: трёхфазный pipeline компрессии
Фаза 1: LZ77 с адаптивным поискомZstd использует продвинутые стратегии поиска: от быстрой hash chain (level 1–3) до оптимального match finder с binary tree (level 16+). Больше уровень → больше окно → глубже поиск → лучше matches, но медленнее.
литералы + matches
Фаза 2: Finite State Entropy (tANS)tANS — table-based ANS. Таблица из 256–4096 записей (configurable). Кодирует литералы, match lengths, и match offsets как дробные биты. Таблица строится per-block из реальных частот — адаптируется к данным.
сжатый поток бит
Фаза 3: Frame/Block framingZstd разбивает поток на фреймы (frame header + blocks). Каждый блок — до 128 KB сжатых данных. Блоки бывают: Raw (литералы), RLE (один символ × count), Compressed (LZ77+ANS). Декодер обрабатывает блоки независимо — параллелизм.

Почему 22 уровня

Zstd: что настраивается на каждом уровне
LevelУровень компрессии Zstd (1–22)
Стратегия поискаАлгоритм поиска совпадений в LZ77
ОкноРазмер скользящего окна для LZ77 поиска
CompressСкорость сжатия (типичная на 1 CPU core)
RatioКоэффициент сжатия на типичных данных (Canterbury corpus)
1 (fastest)Минимальный уровень. Hash chain с 1 probe. Окно 64 KB. Не ищет лучший match — берёт первый. Скорость близка к LZ4.
Fast (1 probe)Single hash table probe. Нашёл совпадение 4+ байт — берём. Не проверяем, есть ли лучше.
64 KBМаленькое окно — мало кандидатов, но быстрый поиск.
~500 MB/sСопоставимо с Snappy. CPU-bound: hash lookup + memcpy.
~2.9xУже лучше Snappy (~2.4x) за счёт ANS фазы.
3 (default)Default level. Hash chain с 4 probes + lazy matching. Окно 256 KB. Баланс скорости и сжатия.
Lazy (4 probes)Hash chain с несколькими probes. Lazy matching: нашёл match, но проверяет следующую позицию — вдруг match длиннее. Выбирает лучший из двух.
256 KBСреднее окно — хороший баланс.
~300 MB/sМедленнее level 1, но всё ещё быстрее GZIP.
~3.5xЗначительно лучше Snappy. Близко к GZIP, но в 6x быстрее.
9Высокий уровень. Binary tree match finder. Окно 8 MB. Ищет лучший match по всему окну.
BTree (deep)Binary tree для поиска. Находит оптимальный match в окне — лучший по length/offset. Значительно медленнее hash, но лучше сжимает.
8 MBБольшое окно — ловит повторы на расстоянии 8 MB. Полезно для structured data.
~80 MB/sЗначительно медленнее, но всё ещё быстрее GZIP level 6.
~4.2xЛучше GZIP level 9 при большей скорости.
19–22Ultra levels. Optimal parsing: вместо greedy/lazy — полный перебор комбинаций match/literal для минимального размера. Окно до 128 MB. Для архивов и one-time compression.
Optimal parsingDynamic programming: для каждой позиции вычислить оптимальное решение (literal vs match, какой match из нескольких кандидатов). Экспоненциально дороже greedy.
128 MBОгромное окно — ловит повторы на расстоянии сотен мегабайт. Нужно много RAM.
~5 MB/sВ 100x медленнее level 1. Только для one-time archival compression.
~4.8xМаксимальное сжатие. Лучше GZIP level 9 на ~15–20%.

22 уровня Zstd — это комбинаторное пространство из: стратегии поиска (5 вариантов) × размера окна (10+ значений) × глубины поиска × параметров ANS таблиц. Уровни — предустановленные точки на кривой speed/ratio.

LZ4 vs Snappy: почему LZ4 быстрее

LZ4 и Snappy используют одинаковый подход — только LZ77, без энтропийного кодирования. Но LZ4 обычно быстрее при лучшем сжатии. Причины — в деталях формата:

LZ4 vs Snappy: различия в дизайне
АспектКонструктивное решение в формате
SnappyРазработан Google (2011). Приоритет: decode speed.
LZ4Разработан Yann Collet (2011). Упрощённый формат для максимальной throughput.
Формат токенаКак кодируется один токен (литерал или match)
Tag byte + varintSnappy: 1-3 byte tag (2-bit type + length). Varint для длинных литералов. Разные форматы для literal, copy-1, copy-2, copy-4. Декодер: switch по 4 типам.
1-byte token + optionalLZ4: 1-byte token: high nibble = literal_length, low nibble = match_length. Если 15 → дополнительные байты. Простая арифметика, нет branching по типу.
Branch predictionСколько условных переходов в декодере на токен
Switch по 4 типамДекодер Snappy: прочитать tag → switch(type) → 4 ветки кода. Branch misprediction на каждом 4-м токене (статистически). Дорого на modern CPU.
Один форматДекодер LZ4: один формат токена. Прочитать nibbles → copy literal → copy match. Нет switch — линейный код. CPU pipeline не ломается.
Offset encodingКак кодируется offset (расстояние назад до match)
1, 2, или 4 байтаSnappy: 3 размера offset (1B: ≤2KB, 2B: ≤64KB, 4B: ≤4GB). Декодеру нужно switch по tag type, чтобы определить длину offset.
Всегда 2 байта (little-endian)LZ4: offset всегда 2 байта = 16 бит = max 65535. Ограничение: matches только в пределах 64 KB. Но: декодер всегда читает ровно 2 байта — нет branching.
Min matchМинимальная длина совпадения
4 байтаSnappy: минимальный match = 4 байта (copy-1 type). Короче — литерал.
4 байтаLZ4: минимальный match = 4 байта. Совпадает с Snappy.
Block framingОбёртка вокруг сжатых данных
Snappy framing formatSnappy: свой framing протокол с chunk headers, CRC32C checksums per chunk, stream identifier. Overhead: ~12 байт per chunk + CRC вычисление.
Minimal (или без)LZ4 block format: нет checksums, нет framing overhead. LZ4 frame format (опционально): magic + header + blocks + checksum. Для embedded use — raw blocks без overhead.
РезультатИтог
~1.5 GB/s decodeSnappy: ~1.5 GB/s декомпрессия, ~500 MB/s компрессия. Сжатие: ~2.3x.
~2.0 GB/s decodeLZ4: ~2.0 GB/s декомпрессия, ~500 MB/s компрессия. Сжатие: ~2.7x. Быстрее И лучше сжимает за счёт simplified format.
WARNING

Парадокс: LZ4 проще и при этом лучше. Snappy потратил байты формата на поддержку 4 GB offsets и CRC checksums. В реальности: offsets > 64 KB крайне редки (данные обычно блоки по 1–256 KB), а CRC можно проверять на уровне storage (HDFS, S3). LZ4 убрал ненужное и выиграл по обоим метрикам.

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

Внутренняя архитектура четырёх алгоритмов
КомпонентВнутренний компонент алгоритма
SnappyGoogle, 2011
LZ4Yann Collet, 2011
GZIPJean-loup Gailly / Mark Adler, 1992 (DEFLATE)
ZstdYann Collet (Facebook), 2016
Фаза 1: LZ77Поиск повторяющихся последовательностей через скользящее окно
Hash (1 probe)Одна hash table. Один probe per position. Greedy: берёт первый match.
Hash (1 probe)Одна hash table. Один probe. Greedy. Идентичный подход к Snappy, но simplified token output.
Hash chain / BTreeGZIP level 1-3: hash chain. Level 4-9: hash chain + lazy matching. Окно: фиксированные 32 KB (DEFLATE spec).
Adaptive (5 strategies)5 стратегий: fast (hash), dfast (double hash), greedy, lazy, btopt, opt. Автоматический выбор по level. Окно: 64 KB – 128 MB.
Фаза 2: энтропияЭнтропийное кодирование выхода LZ77
НетSnappy не применяет энтропийное кодирование. Выход LZ77 записывается напрямую. Быстро, но субоптимальное сжатие.
НетLZ4 тоже не применяет энтропийное кодирование. Чистый LZ77.
HuffmanDEFLATE: Huffman coding на литералах и match lengths. Статическая или динамическая таблица per block. Целые биты на символ.
tANS (FSE)Finite State Entropy: table-based ANS. Дробные биты на символ. 3 таблицы: literals, match_lengths, match_offsets. Оптимальное энтропийное кодирование.
НастраиваемостьКоличество уровней / параметров
НетSnappy: один режим. Нет levels. Нет настроек. Простота = предсказуемость.
Нет (LZ4) / 1–12 (LZ4HC)LZ4 standard: один режим. LZ4HC (High Compression): 12 уровней с более глубоким поиском. Отдельная библиотека.
1–9GZIP: 9 уровней. Настраивается глубина поиска в hash chain. Окно фиксировано (32 KB) — верхний предел определён DEFLATE spec 1996 года.
1–22Zstd: 22 уровня. Настраиваются: стратегия поиска, размер окна, глубина поиска, размер ANS таблиц, block size. Негативные уровни (-1 – -7): ещё быстрее, хуже сжатие.

Почему это важно для data engineering

Понимание внутренностей даёт практические знания:

Внутренности → практические решения
ЗнаниеЧто мы узнали о внутренностях
Практический выводКак это влияет на решения
LZ77 ищет повторы в окнеРазмер окна определяет, как далеко назад ищутся совпадения
Сортировка колонки перед записью увеличивает повторяемость → лучше сжатиеSorted данные: похожие значения рядом → длинные matches → меньше файл. ORDER BY перед write_parquet — бесплатное улучшение.
ANS кодирует дробные битыZstd сжимает лучше Snappy за счёт ANS фазы
Zstd level 1 ≈ Snappy по скорости, но сжимает на 20–30% лучше (за счёт ANS)Если ваш pipeline использует Snappy 'по привычке' — переход на Zstd-1 бесплатен по latency и экономит storage.
GZIP окно = 32 KB (фиксировано)DEFLATE spec 1996 года ограничивает окно 32 KB. Повторы на расстоянии >32 KB не находятся.
На больших блоках данных (>32 KB) Zstd значительно лучше GZIP: окно до 128 MBParquet pages = 1 MB, ORC stripes = 256 MB. GZIP ищет повторы только в первых 32 KB страницы — остальное 'не видит'. Zstd видит всё.
Snappy/LZ4 — чистый LZ77 без энтропииОтсутствие Huffman/ANS = потолок сжатия ниже
Если данные после encoding уже имеют low entropy (битпакинг, delta) — Snappy/LZ4 достаточноХороший encoding (dict + delta + bit-packing) уже убирает избыточность. Компрессия сверху даёт минимальный выигрыш — энтропия уже низкая. В этом случае LZ4 — разумный выбор: нет overhead ANS/Huffman.

Ключевые выводы

  1. LZ77 — фундамент всех четырёх алгоритмов. Ищет повторяющиеся последовательности в скользящем окне и заменяет на ссылки (offset + length).
  2. Huffman coding — энтропийный кодер GZIP/DEFLATE. Каждый символ = целое число бит. Оптимален для алфавита из 256 символов, но теряет до 1 бит на символ при skewed distribution.
  3. ANS — инновация Zstd. Кодирует дробные биты, достигая энтропии Шеннона. Дает Zstd 10–30% преимущества над GZIP при той же стратегии LZ77.
  4. Zstd = LZ77 (5 стратегий поиска) + tANS (дробные биты). 22 уровня — комбинация размера окна, глубины поиска, и стратегии. Уровень 1 ≈ Snappy по скорости, уровень 22 ≈ лучше GZIP-9 по сжатию.
  5. LZ4 быстрее Snappy за счёт упрощённого формата: один тип токена (нет switch), фиксированный 2-byte offset (нет branching), нет CRC overhead.
  6. Хороший encoding снижает ценность компрессии: если dictionary + delta + bit-packing уже убрали избыточность, разница между Zstd и LZ4 минимальна.

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 7