Сжатие работает практически на каждом слое системы, обычно незаметно. Продюсер жмёт сообщение перед тем, как отправить его в брокер; веб-сервер сжимает тело ответа перед отправкой клиенту; колоночная СУБД хранит данные на диске уже в сжатом виде; ночной бэкап льётся в объектное хранилище тоже сжатым. Ни в одном из этих мест решение «каким кодеком и с каким уровнем» обычно не принимается осознанно — работает дефолт библиотеки, драйвера или системы, унаследованный от кого-то другого и когда-то давно.
Дефолт не случаен, но он один, а мест, где сжатие приходится применять, много, и обмен, который оно совершает, в каждом из них разный. Формула одна: меньше байт на выходе значит меньше нагрузки на сеть, диск и деньги за хранение, но получить эти байты стоит процессорного времени — на сжатие и, отдельно, на разжатие. Цена и выгода этого обмена не постоянны: они зависят от кодека, от уровня сжатия внутри кодека и, сильнее всего, от того, что именно ограничивает систему в конкретном месте — пропускная способность канала, объём хранилища или число будущих обращений к уже записанным данным. Похожий по форме обмен — не CPU против размера, а память против точности ответа — разбирает статья про вероятностные структуры: та же логика «заплатить ресурсом сейчас, чтобы сэкономить его позже», только валюта другая.
Дальше статья разложена на три режима работы, вокруг которых собран и стенд, и текст. Горячий транспорт — там, где сжатие соревнуется с полосой канала и выигрыш решает не ratio сам по себе, а соотношение скорости кодека и этой полосы. Холодное хранение — там, где стоимость сжатия делится на число будущих чтений, и чем больше это число, тем выгоднее жать плотнее и медленнее. Онлайн read-heavy — там, где пишут редко, а читают часто, и вопрос смещается с «жать или нет» на «насколько дорого читать то, что записано максимально плотно». У каждого режима — собственный раздел и собственный измеренный сценарий; здесь они введены как рамка, к которой всё остальное будет возвращаться. Материал продолжает разбор highload-сценариев раздела Performance.
В статье
- Границы метода
- Компромисс: три режима работы
- Матрица кодеков
- Режим «горячий транспорт»
- Режим «холодное хранение»
- Режим «онлайн read-heavy»
- «zstd» — это не один zstd
- Словари
- Колоночное против построчного
- Когда не сжимать
- Карта решения
- Что не воспроизвелось
- Источники
Границы метода
Читатель серии «Вычисления в оперативной памяти» вправе спросить в лоб: не тот же ли это Docker Desktop, который в прошлый раз возвращал time.Since() равным нулю в трети замеров? Отчасти да — часть сценариев этого стенда тоже под Docker Desktop. Но методическая граница здесь проходит не там, где в inmemory, и это стоит показать по пунктам, а не одной фразой.
Начать стоит с того, что не требует оговорок. Размеры сжатого — точны, детерминированы и воспроизводимы у читателя побайтово: во всех прогонах каждого сценария output_bytes и ratio совпали построчно, не «на глаз» — 12 из 12 у сценария matrix на 5 прогонах, 9 из 9 (три реализации × три уровня) у zstd-impl на 4 прогонах, 3 из 3 у incompressible на 4 прогонах, 4 из 4 у columnar на 4 прогонах; у dictionary — один прогон, но размер детерминирован конструкцией (тот же вход, тот же алгоритм, без параллелизма). Корпус, на котором это верно, зафиксирован контрольной суммой: sha256 products.ndjson — 1f73c2ea439c043b8ead2dc3daee85b2ac2f1b7080587e76d81ff6830f83d65d; тот же генератор и тот же seed, что у корпуса inmemory-серии, и суммы у обоих совпадают. Читатель, повторивший прогон на своей машине с этим корпусом и теми же версиями библиотек, получит те же байты — платформа здесь ни при чём.
Со скоростью иначе, и это стоит сказать прямо, а не спрятать в сноску: она CPU-bound и зависит от процессора машины, на которой измерена, — AMD Ryzen 7 5800X3D (8 ядер / 16 потоков), Windows 11 Pro. Абсолютные МБ/с в статье приводятся, но только вместе с этим железом, а в выводы идут отношения между кодеками внутри одного прогона на одной машине, а не сами абсолютные числа.
Больше того, скорость на этой машине никогда не публикуется точкой. Даже после отбрасывания прогревочного прогона (о нём — следующий абзац) разброс скорости для сценария matrix — 9–34% по всем 12 кодек-уровням; у остальных сценариев разброс такого же порядка или шире. Каждое число скорости в статье поэтому — медиана и фактический диапазон [min–max] по нескольким зачётным прогонам, а не одно значение.
Этот разброс — не сырой шум, а измеренный эффект разогрева машины, а не предположение о нём: отдельная диагностика (4 эксперимента плюс вторая серия из 12 прогонов на уже прогретой машине) показала, что разгон занимает единицы минут и насыщается, а не растёт бесконечно, и что прогретое состояние держится часами и переживает границы процесса — 15 отдельных запусков подряд с интервалом около 550–600 мс дали диапазон 132–161 мс на brotli уровня 1, тот же порядок, что и тёплое состояние, а не холодный старт (около 352 мс на проход в исходном холодном прогоне). Поэтому первый прогон каждой серии этой фазы всегда отбрасывается как прогревочный, и публикуются только зачётные — 4 из 5 у matrix, 3 из 4 у zstd-impl и incompressible. Отдельно проверена и отвергнута альтернативная версия — что дело не в разогреве, а во вмешательстве оператора (tasklist, просмотр каталогов во время прогона): контролируемый повтор той же активности с частотой выше разумной для ручных проверок не показал измеримой деградации — 138,4 мс медианы у «шумного» прогона против 144,5 мс у «тихого», разница в пределах шума самой машины.
Разогрев бьёт по кодекам неравномерно, и это прямое следствие для методики, а не любопытный факт в сторону. В исходной серии из трёх холодных прогонов lz4, измеренный первым, пока машина ещё почти не прогрета, между прогонами почти не разошёлся; brotli уровня 1, до которого очередь доходит десятым, разошёлся на 158%. Значит любой однопроходный бенчмарк сжатия на свежезагруженной машине несёт систематическую, а не случайную ошибку, и она тем больше, чем позже в списке измерен конкретный кодек, — единичный прогон в принципе непригоден как источник сравнения кодеков между собой.
Здесь появляется вопрос, который в финале inmemory-серииготовится, с 26 сентября стал неприятной находкой постфактум: где проходит граница, ниже которой таймеру вообще нельзя доверять. Там на той же платформе time.Since() вокруг быстрых операций возвращал ровно 0 в 34,9–46,4% замеров — треть-половина всех измерений на операциях длительностью в сотни микросекунд. Здесь порог заложен в измерение заранее и явно: codecs/measure.go считает замер короче 50 мс недостоверным на фоне шума таймера и планировщика и подбирает число проходов так, чтобы суммарная длительность серии превышала порог, пересчитывая множитель по фактически измеренной медиане, а не только по первому предсказанию, — после этого перехода доля отказов калибровки упала с 7,7–11,3% до нуля на тестовом наборе.
Почему это работает здесь и не сработало бы так в inmemory: 50 мс на этом корпусе — величина, близкая к самой короткой измеряемой операции (разжатие zstd уровня 1 занимает около 41 мс), а не малая доля от неё. Остальные операции матрицы кодеков — десятки и сотни миллисекунд, на один-два порядка больше и порога, и того таймерного артефакта, что отравил inmemory. Там измерялись операции в сотни микросекунд, где сам таймер был источником шума; здесь длительности достаточно велики, чтобы шум таймера потерялся на фоне реальной работы CPU. Отсюда правило чтения всех таблиц ниже: где инструмент это позволяет — для размера — числа точны и совпадают до байта; где не позволяет — для скорости — числа честно шумят, и шум предъявлен, а не скрыт за удобной медианой.
Последняя оговорка — про среду, а не про машину. Сценарий matrix (самая подробная матрица, 12 кодек-уровней) измерен на голом хосте Windows без Docker — единственные числа во всём разделе performance/ этого проекта без слоя виртуализации. Три реализации zstd в сценарии zstd-impl, а также dictionary, columnar и incompressible — в контейнере golang:1.26.3 под Docker Desktop/WSL2, потому что две из трёх реализаций zstd собираются через cgo, а gcc на хосте нет. http-transport идёт ещё дальше — два контейнера, связанные Docker-мостом. Один и тот же klauspost/zstd на одном и том же уровне 1 сжимает 480,35 МБ/с на голом хосте (matrix) и 251,16 МБ/с в контейнере (zstd-impl) — разница не о деградации библиотеки, а о среде. Отсюда практическое правило чтения статьи: скорости между сценариями с разной средой не сравниваются, размеры — сравниваются свободно, потому что от среды исполнения байты на выходе не зависят.
Компромисс: три режима работы
Обмен, который совершает сжатие, всегда один и тот же по форме: доля или разы объёма — против CPU-времени на сжатие и, отдельно, на разжатие. Но эта формула ничего не говорит о том, где проходит выгодная точка, потому что она зависит не от кодека, а от того, что ограничивает систему именно там, где сжатие применяется. Один и тот же zstd на максимальном уровне может быть правильным выбором для ночного архива и заведомо неправильным для горячего пути ответа API — не потому что кодек стал другим, а потому что узкое место разное: в первом случае это объём хранилища и число последующих чтений, во втором — время ответа и загрузка CPU прямо сейчас.
Разложить это на три режима удобно потому, что у каждого — своё узкое место и своё, отдельно проверяемое стендом правило выбора:
| Режим | Узкое место | Правило выбора | Раздел |
|---|---|---|---|
| Горячий транспорт | Полоса канала | Сравнивать скорость кодека с полосой канала в одних и тех же МБ/с, а не ratio с ratio | Режим «горячий транспорт» |
| Холодное хранение | Число будущих перечитываний | Чем больше N перечитываний, тем выгоднее заплатить за сжатие один раз и получить плотность |
Режим «холодное хранение» |
| Онлайн read-heavy | Стоимость чтения на горячем пути | Если скорость разжатия слабо зависит от уровня — жать настолько плотно, насколько позволяет бюджет записи | Режим «онлайн read-heavy» |
Это не три альтернативные интерпретации одних и тех же чисел, а три разных вопроса к одному стенду. Симметрии между сжатием и разжатием тоже нет: цена сжать и цена разжать один и тот же объём у большинства кодеков разная, и именно эта асимметрия делает режимы «холодное хранение» и «онлайн read-heavy» разными вопросами, а не одним — в первом множится на N цена разжатия, во втором важно, насколько эта цена вообще зависит от того, насколько плотно записано. Дальше в статье у каждого режима — собственный измеренный сценарий, а не только рассуждение.
Матрица кодеков
Ниже — сведённые числа сценария matrix: голый хост, 5 прогонов, первый отброшен как прогревочный (см. «Границы метода»), в таблице — 4 зачётных. Размеры и ratio совпали точно во всех 5 прогонах.
| Кодек | Уровень | Размер, байт | Ratio | Сжатие, МБ/с медиана [min–max] | Разжатие, МБ/с медиана [min–max] |
|---|---|---|---|---|---|
| lz4/pierrec | 0 | 8 806 015 | 4,1442 | 499,3 [455,8–527,6] | 1315,1 [1276,0–1328,9] |
| lz4/pierrec | 9 | 7 010 711 | 5,2054 | 32,2 [30,5–34,3] | 1282,5 [1233,0–1291,6] |
| zstd/klauspost | 1 | 5 253 347 | 6,9467 | 480,4 [409,3–536,0] | 903,5 [823,6–926,2] |
| zstd/klauspost | 3 | 5 079 316 | 7,1848 | 429,5 [409,6–475,1] | 779,3 [720,1–845,1] |
| zstd/klauspost | 7 | 4 708 989 | 7,7498 | 250,2 [223,0–259,5] | 787,6 [702,6–818,4] |
| zstd/klauspost | 11 | 4 261 388 | 8,5638 | 56,3 [49,1–58,3] | 796,9 [709,0–866,1] |
| gzip/klauspost | 1 | 6 584 314 | 5,5425 | 367,7 [328,8–404,0] | 469,7 [431,2–492,2] |
| gzip/klauspost | 6 | 5 018 675 | 7,2716 | 218,1 [204,9–233,2] | 567,1 [481,6–599,9] |
| gzip/klauspost | 9 | 4 336 304 | 8,4158 | 24,5 [22,9–25,0] | 686,4 [658,7–699,2] |
| brotli/andybalholm | 1 | 5 580 713 | 6,5392 | 218,7 [189,3–253,2] | 259,2 [208,8–282,0] |
| brotli/andybalholm | 5 | 3 970 251 | 9,1918 | 46,6 [40,1–47,0] | 473,4 [390,8–490,3] |
| brotli/andybalholm | 11 | 3 229 387 | 11,3005 | 0,41 [0,40–0,46] | 461,4 [292,5–511,0] |
Кривая отдачи по уровням не плавная — она резко ломается в разных точках у разных семейств. У zstd уровни 1 и 3 неразличимы по скорости сжатия (диапазоны min–max пересекаются), а ratio при этом чуть растёт (6,9467 → 7,1848) — прирост почти бесплатный. Дальше платить приходится всё дороже: с уровня 3 на 7 скорость сжатия падает в 1,7 раза (429,5 → 250,2 МБ/с) за прирост ratio на 7,9% (7,1848 → 7,7498), а с 7 на 11 — ещё в 4,4 раза (250,2 → 56,3 МБ/с) за прирост всего на 10,5% (7,7498 → 8,5638). У gzip перелом между уровнями 6 и 9 ещё резче: скорость падает почти в 9 раз (218,1 → 24,5 МБ/с) ради прироста ratio на 15,7% (7,2716 → 8,4158) — для большинства сценариев это уже точка, после которой плотность не оправдывает потерю скорости. Крайний случай в матрице — brotli уровня 11 против lz4 уровня 0: плотность выше в 2,7 раза (ratio 11,3005 против 4,1442), а скорость сжатия ниже примерно в 1220 раз (0,41 против 499,3 МБ/с).
Семейств в матрице четыре, а не шесть: snappy сюда не добавлен, потому что занимает ту же нишу, что lz4, — быстро, слабо, — и второй представитель той же ниши не добавил бы новой точки на кривой; xz по той же причине не добавлен рядом с brotli на максимальном уровне — та же архивная ниша и тот же характер компромисса. Оба упомянуты здесь одной строкой как соседи по нише, без собственных чисел, — не потому что о них забыли, а потому что стенд не отвечает на вопрос, который они бы добавили.
Полная матрица, код измерения и способ калибровки числа проходов — в стенде codecs.
Режим «горячий транспорт»
Узкое место этого режима — полоса канала: HTTP-ответ API уходит клиенту, реплика — от брокера потребителю, синхронизация — между дата-центрами. Выигрыш от сжатия здесь не постоянен и не совпадает с выигрышем от ratio самого по себе. Дальше выведена формула порога — не приведена готовой, а выведена по шагам, чтобы вывод можно было проверить самостоятельно для любого кодека и любого канала, не только для чисел этого стенда.
Вывод порога
Без сжатия объём S байт уходит по каналу с полосой B байт/с за время S/B. Со сжатием время складывается из двух шагов, а не одного: сначала сжать исходный объём со скоростью кодека V байт/с, затем передать уже сжатый объём S/r (где r — во сколько раз стал меньше объём) по тому же каналу:
без сжатия: t = S / B
со сжатием: t = S/V + (S/r) / B = S/V + S/(r·B)
Сжатие выгодно, когда второе время меньше первого:
S/V + S/(r·B) < S/B
S входит в каждое слагаемое и сокращается — порог зависит только от скорости кодека, полосы канала и коэффициента сжатия, а не от объёма самих данных:
1/V + 1/(r·B) < 1/B
1/V < 1/B − 1/(r·B)
1/V < (r − 1) / (r·B)
V > B · r / (r − 1)
Тот же переход, решённый относительно B, даёт пороговую полосу B* — полосу канала, ниже которой сжатие ещё выгодно для кодека со скоростью V и коэффициентом r:
B* = V · (r − 1) / r
Пока фактическая полоса канала ниже B* — быстрее сжать и передать сжатое. Выше B* — быстрее отправить как есть.
Как B* зависит от r
B* растёт вместе с r, но не пропорционально и не безгранично: доля B*/V = (r−1)/r асимптотически стремится к 1 и никогда не превышает V.
| r | (r−1)/r | B*/V |
|---|---|---|
| 1,5 | 1/3 | 33,3% |
| 2 | 1/2 | 50,0% |
| 3 | 2/3 | 66,7% |
| 5 | 4/5 | 80,0% |
| 10 | 9/10 | 90,0% |
Переход с r=2 на r=3 поднимает порог на 16,7 процентного пункта (с 50% до 66,7% от V), переход с r=5 на r=10 — уже на 10 (с 80% до 90%): каждое следующее удвоение или утроение ratio отдаёт всё меньше в самом пороге. Практический вывод — как только сжатие хоть сколько-нибудь приличное (r от 3 и выше), B* уже составляет 2/3–9/10 от V, и дальше улучшать ratio ради самого порога почти бессмысленно. Значение имеет не ratio сам по себе, а V в тех же единицах, что и полоса канала: сравнивать нужно скорость кодека с пропускной способностью, а не ratio одного кодека с ratio другого.
Проверка на живом HTTP-пути
Формула выше — не абстракция сама по себе: сценарий http-transport стенда поднимает HTTP-сервер и клиента в двух Docker-контейнерах, связанных мостом compnet, и снимает полную матрицу «полоса × кодек» — 8 полос от 500 до 5000 Мбит/с, 2 независимых прогона по 5 повторов на точку. V и r для расчёта B* взяты из той же таблицы матрицы кодеков выше — отдельных замеров скорости для этого сценария не делалось.
| Кодек | V (медиана сжатия) | r | B* расчётный | Измеренный перелом | Совпадение |
|---|---|---|---|---|---|
| gzip/klauspost, уровень 6 | 218,1 МБ/с | 7,2716 | 188,11 МБ/с ≈ 1504,85 Мбит/с | между 1000 и 1500 Мбит/с | почти точное |
| zstd/klauspost, уровень 3 | 429,51 МБ/с | 7,1848 | 369,73 МБ/с ≈ 2957,84 Мбит/с | между 1500 и 2500 Мбит/с (прогоны разошлись) | расходится на 25–40% |
У gzip расчёт и измерение сошлись почти точно: 1504,85 Мбит/с расчётных против перелома между 1000 и 1500 Мбит/с — предсказание попало чуть выше верхней границы измеренного интервала.
У zstd картина хуже, и не только по величине расхождения. Два прогона разошлись между собой: в первом сжатая передача проигрывает несжатой уже на 2000 Мбит/с, во втором на той же полосе ещё выигрывает и уступает лишь на 2500. Причём в первом прогоне точка 2000 Мбит/с выглядит выбросом — 236,2 мс против 138,2 мс на более широкой полосе 2500 Мбит/с, а время передачи не может расти при росте полосы. Во втором прогоне этот выброс не повторился. Поэтому честная оценка перелома для zstd — интервал от 1500 до 2500 Мбит/с, а не узкая вилка: два прогона здесь не подтверждают друг друга, и разрешить их одним замером на точку с пятью повторами не удалось.
Сузить эту вилку заметно дороже, чем кажется, и стоит объяснить почему — тот же счёт ждёт любого, кто возьмётся измерять порог у себя. Точка перелома находится там, где две кривые почти совпадают, а значит именно в окрестности перелома разница между вариантами минимальна и тонет в шуме первой. Шум здесь складывается из двух независимых источников: собственного разброса кодека, который на этой машине составил от 9 до 34 процентов даже после отбрасывания прогревочного прогона, и разброса сетевого пути поверх него. Чтобы различить два значения, отличающиеся на несколько процентов, нужно на порядок больше повторов, чем чтобы различить значения, отличающиеся вдвое: точность оценки растёт примерно как корень из числа замеров, поэтому сужение интервала вдвое требует вчетверо большего времени.
К этому добавляется то, что длинный свип не бесплатен по времени сам по себе. Каждая точка — это отдельная серия повторов на своей полосе, полос в свипе несколько, а прогонов нужно минимум два, чтобы вообще заметить расхождение вроде описанного выше. Пока свип идёт, машина успевает сместиться: разогрев, о котором сказано в границах метода, занимает минуты и в начале длинного прогона ещё не закончился. Получается замкнутый круг — больше точек и повторов дают лучшую статистику, но удлиняют прогон настолько, что в него начинает вмешиваться дрейф самой платформы. Поэтому в статье оставлен широкий честный интервал: доводить его до узкой вилки имеет смысл на выделенной машине с фиксированной частотой и изолированной сетью, а на рабочей станции такой замер даст ложную точность.
Расхождение — не повод не доверять формуле, а её собственное, количественно объяснимое ограничение. Модель S/V + S/(r·B) не учитывает постоянные издержки на запрос: TCP/HTTP round-trip внутри Docker-моста, аллокацию и копирование буфера сжатого тела, GC, планирование горутин под WSL2. Сверка измеренного времени с предсказанием самой модели (S = 36 493 651 байт, на полосе 5000 Мбит/с) даёт оценку этой надбавки в 5–70 мс, растущую вместе с полосой: у gzip разрыв +38…69 мс (примерно 22–39% от предсказания модели), у zstd — +47…54 мс (примерно 50–58%). Дело не в размере самой надбавки — она сопоставима у обоих кодеков по абсолютной величине, — а в том, с чем она сравнивается. Собственный бюджет времени на сжатие у gzip на этом корпусе — около 167 мс (S/V при V = 218,1 МБ/с), и 40–70 мс постоянных издержек в нём теряются. У zstd бюджет вдвое меньше — около 85 мс, потому что zstd на этом уровне сжимает тот же объём примерно вдвое быстрее, — и та же по абсолютной величине надбавка занимает в нём заметно большую долю. Отсюда и перелом заметно раньше, чем предсказывает идеализированная формула.
Прежде чем списать разрыв на постоянные издержки, стенд проверил и отверг более простое объяснение — что дело не в модели, а в неточности ограничения полосы. Полоса ограничивалась не штатным tc netem/tbf: в ядре WSL2, на котором держится бэкенд Docker Desktop, нет ни встроенных, ни подгружаемых модулей sch_tbf/sch_netem, а modprobe в контейнере недоступен — это ограничение конкретного бэкенда, не общее свойство Linux, и на голом ядре штатный путь через tc, вероятно, сработал бы, но здесь это не проверялось. Полоса ограничивалась на уровне приложения, throttle-кодом стенда. Точность этого ограничения проверена отдельно — на несжатых (identity) запросах, где никакая модель сжатия не участвует: фактическая полоса составила 98,4–99,9% от заданной на всём диапазоне обоих прогонов. Неточность throttle как причина расхождения этим исключается — искомая причина в самой модели, а не в измерительном инструменте.
Вывод для чтения формулы: B* = V·(r−1)/r даёт оценку порядка величины порога, а не точную точку перелома на живом сетевом пути. Она тем точнее, чем больше собственный бюджет времени кодека на сжатие относительно постоянных издержек одного запроса, и систематически завышает порог там, где эти издержки уже сопоставимы с самим временем сжатия — для быстрых кодеков на не очень большом объёме полезной нагрузки это ближе к правилу, чем к исключению.
Практическое правило
Гигабитный канал — это примерно 125 МБ/с (1000 Мбит/с делить на 8 бит в байте). Сравнивать нужно именно так: скорость кодека V в тех же МБ/с против пропускной способности канала, а не ratio одного кодека с ratio другого — предыдущий раздел показал, почему на сам порог ratio влияет слабо, а V и B в одних единицах — сильно и напрямую.
Отсюда и парадокс, который на практике встречается регулярно: сжатие ответа API на localhost или внутри одной стойки часто не ускоряет систему, а замедляет её, а на мобильном клиенте — почти всегда выигрывает, при том же самом кодеке и уровне сжатия. Разница не в V — скорость кодека не меняется от того, кто на другом конце соединения, — а в B. Localhost и связь внутри дата-центра — это гигабиты и десятки гигабит в секунду, то есть гигабайты в секунду, на порядок и больше выше скорости любого кодека из таблицы matrix; мобильная сеть — единицы и десятки мегабит в секунду, то есть на порядок и больше ниже. Как эта оценка вписывается в общий бюджет времени ответа — бюджет задержки. На edge-кэше расчёт смещается ещё сильнее: сжатый ответ считается один раз при попадании в кэш и раздаётся многократно, так что V перестаёт быть частью горячего пути каждого запроса — подробнее в edge-кэшСкоро.
Режим «холодное хранение»
Узкое место здесь другое: не полоса канала, а число будущих чтений уже записанного объекта — архивной копии, холодной партиции колоночной таблицы, ночного бэкапа. Сжимают такой объект один раз, а разжимают потом сколько потребуется: ноль раз, если архив никогда не понадобится, один раз, если это промежуточный результат конвейера, или сотни и тысячи раз, если это горячая часть аналитического хранилища, которая просто редко переписывается. Ответ на вопрос «каким кодеком жать» зависит именно от этого числа, а не от природы данных самой по себе.
Разжатие дешевле сжатия — но насколько
У всех 12 строк матрицы кодеков разжатие быстрее сжатия того же объёма, но не на одну и ту же величину — расхождение растёт вместе с уровнем сжатия внутри каждого семейства, а не остаётся постоянным:
| Кодек | Уровень | Сжатие, МБ/с | Разжатие, МБ/с | Разжатие дешевле сжатия, во сколько раз |
|---|---|---|---|---|
| lz4/pierrec | 0 | 499,3 | 1315,1 | 2,63× |
| lz4/pierrec | 9 | 32,2 | 1282,5 | 39,83× |
| zstd/klauspost | 1 | 480,4 | 903,5 | 1,88× |
| zstd/klauspost | 11 | 56,3 | 796,9 | 14,16× |
| gzip/klauspost | 1 | 367,7 | 469,7 | 1,28× |
| gzip/klauspost | 9 | 24,5 | 686,4 | 28,02× |
| brotli/andybalholm | 1 | 218,7 | 259,2 | 1,19× |
| brotli/andybalholm | 11 | 0,41 | 461,4 | примерно 1125× |
На самом слабом уровне каждого семейства асимметрия скромная — от 1,19× у brotli-1 до 2,63× у lz4-0: разжатие чуть дешевле, но не радикально. На самом плотном уровне того же семейства разрыв уже на порядок и больше: 14–40× у zstd-11, gzip-9 и lz4-9, и около 1125× у brotli-11 — там, где сжатие тратит почти полторы минуты CPU-времени на переборе вариантов кодирования, разжатие того же результата остаётся обычной операцией на сотни МБ/с. Смысл не в конкретных множителях, а в направлении: у всех 12 строк без исключения разжатие дешевле, и чем плотнее ужат вывод, тем больше становится этот разрыв — сжатие делает тяжёлую работу поиска избыточности один раз, а разжатие потом её просто применяет, независимо от того, насколько трудным был поиск.
Полная стоимость при N чтениях
Асимметрия сама по себе — ещё не ответ на вопрос «каким кодеком жать», а входные данные для него. Ответ зависит от того, сколько раз объект прочитают. Модуль analysis стенда считает это буквально — сжать один раз, разжать reads раз:
func TotalCostMs(compressMs, decompressMs float64, reads int) float64 {
return compressMs + decompressMs*float64(reads)
}
Цена сжатия входит в сумму один раз и с ростом N перестаёт что-либо значить; цена разжатия входит с множителем N и при больших N определяет всё. Возьмём gzip уровня 1 (быстрое и слабое сжатие) против уровня 9 (медленное и плотное) на том же корпусе, что и вся матрица кодеков (S = 36 493 651 байт — тот же корпус, что и в проверке формулы порога выше). По медианным скоростям из матрицы, сжатие и разжатие всего корпуса занимают:
| Кодек/уровень | Сжатие, мс | Разжатие, мс |
|---|---|---|
| gzip/klauspost, уровень 1 | 94,7 | 74,1 |
| gzip/klauspost, уровень 9 | 1420,5 | 50,7 |
А полная стоимость TotalCostMs при N = 1, 10, 100, 1000 перечитываниях:
| N | gzip-1, мс (быстрый, слабый) | gzip-9, мс (медленный, плотный) | Дешевле |
|---|---|---|---|
| 1 | 168,8 | 1471,2 | gzip-1 в 8,7 раза |
| 10 | 835,7 | 1927,5 | gzip-1 в 2,3 раза |
| 100 | 7504,7 | 6490,5 | gzip-9 в 1,16 раза |
| 1000 | 74194,7 | 52120,5 | gzip-9 в 1,42 раза |
Выбор переворачивается между N = 10 и N = 100. Точку перелома можно найти напрямую из тех же чисел: избыточная цена сжатия у уровня 9 (1420,5 − 94,7 = 1325,8 мс) делится на экономию за одно чтение (74,1 − 50,7 = 23,4 мс) — 1325,8 / 23,4 ≈ 57 чтений. До 57 перечитываний дешевле быстрый и слабый gzip-1, после — медленный и плотный gzip-9, и с каждым следующим чтением разрыв в его пользу растёт линейно.
Крайний случай матрицы доводит эту же арифметику до предела в обратную сторону. У brotli уровня 11 сжатие всего корпуса — не миллисекунды, а около 84 885 мс (порядка полутора минут CPU-времени), тогда как разжатие — обычные 75,4 мс, ненамного дороже, чем у brotli уровня 1 (134,3 мс). Экономия на разжатии здесь есть (134,3 → 75,4 мс, почти вдвое), но она слишком мала, чтобы быстро отбить настолько дорогое сжатие: точка перелома с уровнем 1 (сжатие 159,1 мс, разжатие 134,3 мс) наступает около N ≈ (84885,4 − 159,1) / (134,3 − 75,4) ≈ 1440 чтений — за пределами даже последнего столбца таблицы выше. На 1000 перечитываниях brotli-11 всё ещё дороже brotli-1 (160 285,4 мс против 134 459,1 мс). Большое N само по себе не гарантирует, что самый плотный уровень окупится — гарантирует только N, достаточно большое для конкретной пары цены сжатия и экономии на чтении, а эта пара у разных кодеков и уровней совершенно разного порядка.
Практический вывод
Архив, ночной бэкап и холодная партиция, которую поднимут ещё сотни раз, требуют одного решения — плотного и медленного кодека, потому что цена сжатия амортизируется на множестве будущих чтений, а выигрыш в объёме копится с каждым из них. Промежуточный результат конвейера, который прочтут ровно один раз и выбросят, требует противоположного — быстрого и слабого, потому что для него нет амортизации: цена сжатия и цена единственного разжатия складываются один раз и никогда не делятся. Делит эти два случая не тип данных — тот же самый объект в одном сценарии архивный, в другом одноразовый, — а число N.
Режим «онлайн read-heavy»
Здесь узкое место — не полоса канала и не число перечитываний, а стоимость каждого отдельного чтения на горячем пути: кеш, сериализованный ответ API, страница колоночного хранилища, к которой обращаются на каждый запрос. Пишут в этом режиме редко относительно того, как часто читают, и вопрос смещается с «жать или нет» на «насколько дорого читать то, что записано максимально плотно» — потому что если разжатие плотного вывода почти не дороже разжатия слабого, платить за плотность имеет смысл один раз, на записи, и не думать о ней на каждом чтении.
Гипотеза, с которой начинался этот раздел до проверки на стенде, была конкретной: у zstd скорость разжатия почти не зависит от уровня, и именно это отличает его от остальных кодеков — с zstd можно жать настолько плотно, насколько позволяет бюджет записи, не оглядываясь на цену чтения. Проверка эту гипотезу не подтвердила: на этих данных и в этой реализации утверждение «разжатие zstd не зависит от уровня» не установлено — систематической зависимости не видно, но разброс измерения не позволяет её ни подтвердить, ни исключить, а одна значимая пара направлена прямо против гипотезы.
Проверка: перекрытие диапазонов по уровням
Критерий значимости здесь тот же, что и в матрице кодеков — непересечение диапазонов min–max: если диапазоны двух уровней не пересекаются, разница не объясняется одним только шумом измерения. У zstd/klauspost из шести возможных пар уровней (1↔3, 1↔7, 1↔11, 3↔7, 3↔11, 7↔11 — по четырём уровням матрицы) неразличимы пять; значима ровно одна — 1 против 7, и направление там не то, что предсказывала гипотеза: разжатие замедляется (903,5 → 787,6 МБ/с, диапазоны 823,6–926,2 и 702,6–818,4 не пересекаются). При этом сама эта единственная значимая пара не согласуется с соседними: пара 1 против 11 должна была бы отличаться сильнее, чем 1 против 7, — уровень 11 самый плотный из четырёх, — но она неразличима (823,6–926,2 и 709,0–866,1 пересекаются). Причина не в том, что зависимость на предельном уровне пропадает, а в том, что у уровня 11 разброс по разжатию заметно шире (709,0–866,1, размах 19,7% от медианы) и перекрывает и уровень 1, и уровень 7 сразу. Перед нами эффект разброса измерения, а не систематическая зависимость, которая сначала появляется на уровне 7, а потом исчезает на уровне 11.
У gzip и brotli, наоборот, значимые пары нашлись, и обе показывают ускорение, а не замедление или отсутствие эффекта. У gzip уровень 1 против уровня 9: 469,7 → 686,4 МБ/с, диапазоны 431,2–492,2 и 658,7–699,2 не пересекаются. У brotli уровень 1 против уровня 5: 259,2 → 473,4 МБ/с, и уровень 1 против уровня 11: 259,2 → 461,4 МБ/с — обе пары значимы, и в обоих случаях разжатие более плотного вывода быстрее, а не медленнее. У lz4 уровни 0 и 9 неразличимы: 1315,1 [1276,0–1328,9] против 1282,5 [1233,0–1291,6] МБ/с, диапазоны пересекаются — между самым слабым и самым плотным уровнем на этом корпусе стенд не увидел разницы в разжатии вовсе.
Что подтвердилось, а что нет
Утверждать по этим данным можно следующее: систематического удорожания разжатия с ростом уровня не установлено ни у одного семейства, а у gzip и brotli на этом корпусе более плотный вывод даже ускоряет разжатие. По zstd данные неоднозначны — единственная значимая пара направлена против общей картины, и сказать, что разжатие там не дорожает нигде, эти измерения не позволяют. Утверждать нельзя, что «разжатие zstd не зависит от уровня» — именно так была сформулирована исходная гипотеза этого раздела, и проверка её не подтвердила: систематической зависимости у zstd не видно, но разброс измерения не позволяет её разрешить, а пара 1↔7 прямо ей противоречит направлением (замедление, а не постоянство).
Это касается конкретно klauspost на голом хосте — том самом окружении, где измерена вся матрица кодеков, и здесь стоит оговориться, не свойство ли это конкретной реализации, а не алгоритма zstd вообще. Раздел «zstd» — это не один zstd ниже сравнивает klauspost с двумя cgo-обвязками поверх libzstd в общем для них контейнерном окружении, и там для того же klauspost пара уровней 1 против 7 по разжатию тоже значима, но направление обратное — ускорение (465,8 → 541,2 МБ/с), а не замедление. Однозначно объяснить это стенд не может: неясно, определяет ли направление среда исполнения (контейнер против голого хоста) или сама эта граница у klauspost попросту неустойчива и рассказывает больше о шуме, чем о zstd, — эта гипотеза отдельно не проверялась. Раздел про три реализации стоит читать не как противоречие написанному здесь, а как более прямой ответ на вопрос «свойство алгоритма или конкретной библиотеки».
Правдоподобное объяснение, а не установленная причина
Ускорение разжатия у gzip и brotli с ростом уровня правдоподобно объясняется просто: чем плотнее вывод кодировщика, тем меньше байт вообще нужно прочитать и пропустить через декодер на входе, и на этом корпусе экономия на объёме перевешивает усложнение самого декодирования. Стенд не умеет разложить время разжатия на «чтение байт» и «работу декодера» по отдельности, поэтому это рассуждение остаётся правдоподобным объяснением эффекта, а не установленным стендом механизмом, и предъявляется в статье именно так.
Практический вывод
Вывод режима не требует знания причины эффекта, но и не может быть шире измеренного. Систематического удорожания чтения с ростом плотности записи эти замеры не установили ни у одного семейства. У zstd при этом есть один значимый неблагоприятный сигнал — пара уровней 1 и 7, где разжатие замедляется, — но он не складывается в монотонную зависимость: остальные пять пар, включая крайнюю 1 против 11, неразличимы. У gzip и brotli более плотный вывод чтение значимо ускоряет, у lz4 уровни неразличимы. Практическое следствие формулируется через это: на read-heavy пути поднимать плотность имеет смысл настолько, насколько позволяет бюджет записи, но для zstd стоит проверить поведение на своём корпусе, а не полагаться на общее правило.
«zstd» — это не один zstd
На предыдущих трёх режимах в таблицах фигурировал один zstd — klauspost/compress, чистая Go-реализация без cgo. Но «zstd» в Go-проекте может значить три разные библиотеки: klauspost/compress сам кодирует формат на чистом Go, а valyala/gozstd и DataDog/zstd — это cgo-обвязки поверх эталонной библиотеки libzstd, написанной на C. Сценарий zstd-impl берёт один корпус, три номинально одинаковых уровня (1, 3, 7) и все три реализации в общем окружении — контейнере golang:1.26.3 — специально для того, чтобы разница была видна между реализациями, а не между хостом и контейнером.
| Реализация | Уровень | Размер, байт | Ratio | Сжатие, МБ/с медиана [min–max] | Разжатие, МБ/с медиана [min–max] |
|---|---|---|---|---|---|
| zstd/klauspost | 1 | 5 253 347 | 6,9467 | 251,2 [196,2–274,2] | 465,8 [423,2–505,7] |
| zstd/valyala-cgo | 1 | 4 816 823 | 7,5763 | 394,8 [366,4–412,5] | 1199,3 [1128,6–1321,9] |
| zstd/datadog-cgo | 1 | 4 816 823 | 7,5763 | 390,6 [363,1–411,0] | 1190,7 [1148,5–1222,8] |
| zstd/klauspost | 3 | 5 079 316 | 7,1848 | 271,2 [240,6–295,2] | 524,4 [480,4–544,9] |
| zstd/valyala-cgo | 3 | 5 009 696 | 7,2846 | 292,6 [290,0–312,3] | 1159,3 [1124,3–1248,3] |
| zstd/datadog-cgo | 3 | 5 009 696 | 7,2846 | 297,6 [193,1–318,5] | 1135,6 [923,1–1264,6] |
| zstd/klauspost | 7 | 4 708 989 | 7,7498 | 124,2 [120,8–139,6] | 541,2 [538,9–618,8] |
| zstd/valyala-cgo | 7 | 4 060 889 | 8,9866 | 74,5 [71,2–77,3] | 1550,8 [1376,0–1602,1] |
| zstd/datadog-cgo | 7 | 4 060 889 | 8,9866 | 73,0 [69,6–75,2] | 1400,3 [573,9–1588,2] |
По размеру klauspost не совпадает ни с одной из cgo-обвязок ни на одном из трёх уровней, а valyala-cgo и DataDog-cgo совпадают между собой побайтово на каждом — 4 816 823 байт на уровне 1, 5 009 696 на уровне 3, 4 060 889 на уровне 7. Это ожидаемо: обе обвязки вызывают один и тот же C-кодировщик libzstd через cgo, разница между ними — только в связывании, не в алгоритме кодирования. Разрыв klauspost относительно них неравномерный: вывод klauspost больше на 8,3% на уровне 1, на 1,4% на уровне 3 и на 13,8% на уровне 7 — не монотонная прогрессия, потому что причина не в «худшем сжатии вообще», а в конкретных внутренних параметрах кодировщика на конкретном уровне. Совместимость формата при этом не страдает: TestZstdImplementationsInterop подтверждает, что любая из трёх реализаций разжимает вывод любой другой побайтово идентично исходному входу — разные реализации кодируют по-разному, но формат один и совместим в обе стороны.
Причина расхождения — не в качестве реализации klauspost, а в её устройстве. klauspost/compress v1.19.0 транслирует номинальный уровень zstd в один из четырёх внутренних пресетов кодировщика: EncoderLevelFromZstd (zstd/encoder_options.go, строки 201–215) — уровни ниже 3 идут в SpeedFastest, 3–5 — в SpeedDefault, 6–9 — в SpeedBetterCompression, 10 и выше — в SpeedBestCompression, и doc-комментарий к функции прямо предупреждает: несколько входных значений уровня дают один и тот же результат сжатия. Прямой замер это подтверждает: уровни 3 и 5 на корпусе этого стенда дают побайтово идентичный вывод — 5 079 316 байт, один и тот же результат под двумя разными номинальными уровнями. Уровни сценария (1, 3, 7) выбраны не случайно — каждый попадает в свой пресет klauspost, поэтому расхождение с cgo видно на всех трёх уровнях, а не только там, где случайно задета граница пресета. cgo-обвязки квантования не делают: они прокидывают номинальный уровень напрямую в C-кодировщик, который честно перебирает набор параметров, соответствующий именно этому уровню, без округления до одного из четырёх грубых классов.
По скорости картина не в пользу одной стороны однозначно. На сжатии cgo-обвязки быстрее klauspost на уровнях 1 и 3 — в 1,08–1,57 раза, но на уровне 7 медленнее: klauspost — 124,2 МБ/с, обе cgo-обвязки — около 74 МБ/с, притом что итоговый ratio у cgo на этом уровне выше (8,9866 против 7,7498 у klauspost). На разжатии асимметрия не переворачивается ни разу: обе cgo-обвязки систематически в 2,2–2,9 раза быстрее klauspost на всех трёх уровнях — разжатие через C почти всегда обгоняет чистый Go на этом корпусе. Отдельно стоит упомянуть разжатие datadog-cgo на уровне 7: разброс между зачётными прогонами там необычно широкий, 72,4% — фактический выброс в одном из трёх прогонов, причина не диагностирована; на направление вывода это не влияет, но делает именно это число менее надёжным, чем соседние в той же таблице.
Здесь актуальна та же оговорка, что и в «Границах метода» статьи: zstd-impl целиком измерен в контейнере, а вся «Матрица кодеков» выше — на голом хосте. Тот же klauspost на том же уровне 1 сжимает 480,4 МБ/с на хосте и лишь 251,2 МБ/с здесь — разница про среду исполнения, а не про деградацию библиотеки. Внутри zstd-impl сравнение трёх реализаций корректно — все три измерены в одном контейнере, на одних и тех же прогонах, — но скорости из этого раздела не сравниваются со скоростями из «Матрицы кодеков». Размеры сравнивать можно свободно в любом случае, они от среды исполнения не зависят.
Отсюда практическое следствие для любого, кто сверяет свои числа с чужим бенчмарком: фраза «zstd уровня 3» сама по себе ничего не говорит, пока не известны реализация и версия библиотеки. У klauspost уровень 3 может побайтово совпасть с его же уровнем 5; у cgo-обвязки поверх libzstd — нет. Число из чужой статьи или из документации сопоставимо с вашим ровно тогда, когда совпадают и реализация, и версия — иначе сравниваются не алгоритмы, а два случайно выбранных набора внутренних решений двух разных кодировщиков. Это ломает переносимость чисел между статьями и инструментами сильнее, чем кажется на первый взгляд: недостаточно написать «сжато zstd уровня 3», нужно указывать ещё и библиотеку.
Словари
Матрица кодеков и оба «горячих» режима выше работали с одним большим объектом — файлом на десятки мегабайт, который сжимают целиком. У сценария dictionary вход другой: мелкие однотипные сообщения порядка ста байт каждое — события product_viewed из корпуса events-small.ndjson. На сообщении такого размера обычное сжатие почти бесполезно: ему неоткуда взять внутреннюю избыточность — оно слишком короткое, чтобы таблица совпадений и коды Хаффмана внутри самого кодировщика успели себя окупить.
Прежде чем приводить числа — как именно они посчитаны, потому что без этого результат нечитаем. Сценарий сжимает каждое сообщение по отдельности и суммирует размеры получившихся блоков, а не склеивает сообщения в один поток перед сжатием. Разница принципиальная: склейка дала бы кодировщику контекст соседних сообщений — совпадения между сообщением №5 и сообщением №4000 он нашёл бы и использовал так же, как находит их внутри одного большого файла. В реальной очереди или логе этого контекста нет: продюсер публикует сообщение №5, потребитель читает и разжимает его само по себе, не зная и не имея доступа к тому, что было в сообщении №4000. Замер по склеенному потоку измерил бы не то, что происходит в проде, а искусственно улучшенный случай — ровно то количество межсообщенческого контекста, ради отсутствия которого словарь и применяется: словарь — способ отдать кодировщику общий контекст сообщений заранее, обучением, а не подглядыванием в соседние сообщения во время самого сжатия. Второе методическое условие — раздельные выборки: словарь обучен на первых 20 000 сообщений events-small.ndjson (всего в файле 200 000 событий), а замер сжатия проведён на оставшихся 180 000. Если бы словарь обучали и измеряли на одной и той же выборке, результат оказался бы завышен переобучением словаря под те же самые сообщения, которые он потом сжимает.
| Режим | Input суммарно, байт | Output суммарно, байт | Ratio |
|---|---|---|---|
| Без словаря (zstd/klauspost, уровень 3) | 18 398 951 | 17 882 057 | 1,0289 |
| Со словарём (zstd/klauspost+dict, уровень 3) | 18 398 951 | 8 352 948 | 2,2027 |
На 180 000 отдельно сжатых сообщений (18 398 951 байт входа суммарно, около 102 байт на сообщение) обычное сжатие даёт ratio 1,0289 — экономия 2,9%, в пределах шума для практических целей. Словарь на том же входе даёт ratio 2,2027: сжатый объём падает с 17 882 057 до 8 352 948 байт. Разница между режимами не в самом кодеке — это тот же zstd/klauspost, тот же уровень 3, — а в том, откуда кодировщик берёт таблицу совпадений: без словаря только из самого сообщения, которого не хватает, со словарём — заранее, из статистики 20 000 других однотипных сообщений. Словарь выигрывает у обычного сжатия в 2,14 раза (2,2027 / 1,0289) именно на этом классе входа — там, где обычное сжатие почти бесполезно.
Второй вопрос напрашивается сам собой — до какого размера сообщения этот выигрыш держится и с какого момента обычное сжатие догоняет словарь просто потому, что самому сообщению уже хватает внутренней избыточности. Стенд отвечает на него не полностью: единственный измеренный класс входа — сообщения около 102 байт, размер payload в сценарии не варьировался, и точка, где кривая выигрыша словаря пересекается с кривой обычного сжатия, эмпирически не найдена. Разумно ожидать, что выигрыш сокращается по мере роста сообщения — постоянные издержки словаря размываются, а тело сообщения само начинает нести достаточно избыточности для обычного кодировщика, — но это рассуждение, а не измерение: конкретного числа байт, где кривые сходятся, в фикстурах этого стенда нет.
Отдельно стоит зафиксировать ловушку конкретной версии библиотеки, найденную по пути. BuildDict в klauspost/compress v1.19.0 падает (integer divide by zero, zstd/dict.go:431) при обучении на вырожденно малой выборке — тест обучал словарь на трёх коротких сообщениях со смещениями [1, 4, 8]. Рабочий сценарий, обучающийся на 20 000 сообщений, этот дефект не задевает — тест, который его поймал, переписан на генерируемую выборку из 512 сообщений. Это свойство конкретной реализации BuildDict на конкретном вырожденном входе, а не общее свойство словарного сжатия как метода: с обучающей выборкой разумного размера, какой в проде она обычно и будет, падения нет.
Скорость в этом разделе не приводится не случайно — сценарий dictionary отвечает на вопрос о размере, а скорость обучения словаря и сжатия с ним отдельно не измерялась.
Колоночное против построчного
Сценарий columnar держит постоянным всё, кроме одной переменной — порядка байт. Один и тот же набор значений одной и той же таблицы записан двумя способами: table-row — построчно, значения одной записи идут подряд, table-col — колоночно, значения одного столбца идут подряд, запись за записью. Кодек и уровень в каждой паре сравнения тоже постоянны — zstd/klauspost уровня 3 и gzip/klauspost уровня 6, те же, что фигурируют в остальных разделах статьи. Единственная переменная — раскладка байт на диске до сжатия.
| Профиль | Кодек | Размер, байт | Ratio | Сжатие, МБ/с медиана [min–max] | Разжатие, МБ/с медиана [min–max] |
|---|---|---|---|---|---|
| table-row | zstd/klauspost-3 | 2 829 450 | 3,9596 | 182,4 [105,9–195,1] | 618,7 [527,1–638,8] |
| table-row | gzip/klauspost-6 | 2 650 355 | 4,2271 | 85,5 [67,0–114,0] | 236,2 [199,8–250,1] |
| table-col | zstd/klauspost-3 | 2 291 340 | 4,8895 | 142,2 [116,9–202,2] | 613,8 [468,4–643,3] |
| table-col | gzip/klauspost-6 | 2 585 984 | 4,3324 | 112,7 [109,2–119,5] | 283,7 [253,1–295,2] |
Колоночная раскладка помогает, но не одинаково — выигрыш зависит от кодека, а не является свойством самой раскладки. У zstd ratio растёт с 3,9596 до 4,8895 — на 23,5%; у gzip с 4,2271 до 4,3324 — на 2,5%, в разы меньше. При этом на построчной раскладке gzip даёт ratio выше, чем zstd (4,2271 против 3,9596), а на колоночной уже наоборот, zstd впереди (4,8895 против 4,3324) — раскладка не просто добавляет к обоим кодекам одинаковый бонус, она меняет их относительный порядок.
Здесь стоит сразу оговорить методическую разницу этого сценария с остальными в статье: columnar — единственный из измеренных в контейнере сценариев, где прогревочный прогон не исключён, все 4 прогона сведены без отбрасывания первого. Из-за этого разброс скорости здесь шире, чем у matrix или zstd-impl, — до 60,0% по сжатию у table-col/zstd. На размер и ratio это не влияет: они, как и везде в статье, совпали точно во всех 4 прогонах, — но абсолютные числа скорости в этой таблице стоит читать с поправкой на более широкий диапазон [min–max], который приведён честно, а не сглажен усечением до трёх прогонов, как в остальных сценариях.
Правдоподобное объяснение, почему разница между кодеками именно такая, — размер окна поиска совпадений. У gzip/DEFLATE это окно фиксировано форматом — 32 КБ по спецификации; кодировщик ищет повторы только в пределах последних 32 КБ уже обработанного потока и физически не видит совпадений дальше этой границы. Колонка в этом корпусе — 11 203 432 байт на всю таблицу, на порядки больше 32 КБ; повторяющиеся значения одного столбца, разнесённые на мегабайты друг от друга в колоночной раскладке, для gzip оказываются за пределами окна и в сжатие не попадают, сколько бы их ни было. У zstd окно кодировщика на порядок и больше шире, поэтому он видит и использует куда большую долю той же избыточности. Это объяснение опирается на документированное устройство форматов DEFLATE и zstd, а не на измерение этого стенда — стенд размер окна поиска не варьировал и не изолировал этот фактор напрямую, поэтому здесь оно приведено как вероятная причина, а не установленный стендом механизм.
То же устройство — «окно поиска против расстояния между повторами» — объясняет, почему движки храненияСкоро с колоночной раскладкой вообще выигрывают от сжатия сильнее построчных: колонка группирует похожие значения физически рядом, придвигая повторы друг к другу так, что они попадают в окно поиска почти любого кодека, а не только тех, у кого окно широкое. То же соображение стоит за выбором раскладки сегментов лога и компакцией в Kafka — подробнее в статье про хранение в Kafka.
Практический вывод для выбора кодека под колоночное хранилище — раскладка не отменяет разницу между кодеками, она усиливает её избирательно: кодек с узким окном поиска (gzip) от неё выигрывает мало, кодек с широким окном (zstd) — заметно больше. Если раскладка данных уже колоночная, есть смысл пересчитать выбор кодека заново, а не переносить решение, принятое для построчного хранения.
Когда не сжимать
Все разделы выше исходили из того, что в данных есть избыточность и вопрос только в том, каким кодеком и насколько плотно её собрать. Сценарий incompressible проверяет противоположный случай — что происходит, если избыточности нет вовсе. Вход — 64 МиБ (67 108 864 байт) псевдослучайного потока, детерминированно сгенерированного (seed=7) так, что байты статистически неотличимы от равномерного шума: ровно то, что энтропийное кодирование в принципе не умеет сжимать, потому что сжимать там нечего.
| Кодек | Размер, байт | Δ, байт | Ratio | Сжатие, МБ/с медиана [min–max] | Разжатие, МБ/с медиана [min–max] |
|---|---|---|---|---|---|
| lz4/pierrec, уровень 0 | 67 108 943 | +79 | 0,999999 | 2186,6 [2055,0–2219,1] | 1539,0 [1483,4–1719,4] |
| zstd/klauspost, уровень 3 | 67 110 413 | +1 549 | 0,999977 | 1357,5 [1238,2–1395,8] | 1009,0 [954,2–1037,1] |
| gzip/klauspost, уровень 6 | 67 114 009 | +5 145 | 0,999923 | 1829,2 [1687,4–1853,7] | 2245,3 [2071,1–2411,6] |
Результат недвусмысленный — все три кодека увеличили размер, ни один не дал ratio выше 1. Лучший случай — lz4 (+79 байт, +0,00012%), худший — gzip (+5 145 байт, +0,0077%): разница между ними не в умении сжимать (сжимать одинаково нечего), а в весе контейнерного формата каждого кодека — заголовков, границ блоков, контрольных сумм, которые записываются независимо от того, нашёл кодировщик избыточность или нет. У lz4 этот формат самый лёгкий из трёх, у gzip — самый тяжёлый.
Рост размера — не единственная цена этой попытки. Процессорное время на сжатие тратится независимо от результата: по медианной скорости из таблицы это около 30,7 мс у lz4 (67 108 864 байт / 2186,6 МБ/с), 36,7 мс у gzip (/ 1829,2 МБ/с) и 49,4 мс у zstd (/ 1357,5 МБ/с) на такой блок. Для 64 МиБ несжимаемых данных это заметное процессорное время, потраченное на результат, который не просто бесполезен, а отрицателен: объём вырос и CPU потрачен впустую одновременно, без единого компенсирующего эффекта.
Два практических случая, где вывод «не сжимать» верен, скорее всего, ещё сильнее, чем видно из этого сценария, стендом напрямую не измерялись — здесь они приводятся как рассуждение, а не измерение, и помечены так явно.
Первый — мелкие payload’ы. В таблице выше издержки контейнерного формата (те самые +79…+5 145 байт) потерялись на фоне блока в 64 МиБ — доля меньше сотой процента. Тот же абсолютный оверхед формата на payload размером в несколько сотен байт или несколько килобайт — заголовок кодека, служебные таблицы, контрольная сумма — уже не потеряется, а может стать сопоставимым с самим полезным содержимым или превысить его: сообщение вырастет не на доли процента, а на заметную часть своего размера. Этот сценарий стенд не измерял ни на одном payload меньше 64 МиБ — вывод для мелких сообщений это экстраполяция того же механизма (контейнерный формат стоит фиксированную цену независимо от содержимого), а не отдельное измерение.
Второй — путь, критичный к задержке. Даже там, где сжатие формально не увеличивает объём, время на сжатие и разжатие добавляется к задержке запроса целиком, а не амортизируется на будущих чтениях, как в режиме «холодное хранение» выше, — и именно эта прибавка на каждый запрос, а не среднее по всем запросам, определяет то, что статья про хвостовые перцентилиСкоро называет выбросами p99: несколько лишних миллисекунд CPU почти не сдвигают среднее время ответа, но систематически утяжеляют хвост распределения. Если данные на этом пути уже плотные или почти несжимаемые — сериализованный бинарный протокол, уже сжатые вложения, зашифрованные блобы, — CPU на попытку дополнительно их сжать становится чистыми накладными расходами к задержке без компенсации в объёме. Это тоже не измерялось этим стендом напрямую: incompressible не замерял latency отдельно от throughput, — но логика та же, что и во всей статье: раз узкое место здесь не объём и не число будущих чтений, а задержка одного конкретного запроса, сжатие данных без избыточности не просто не помогает — оно вредит без исключений.
Практический вывод раздела — прежде чем сжимать, стоит проверить, есть ли в данных вообще что сжимать. Уже сжатые форматы (JPEG, MP4, ZIP-архивы), зашифрованные потоки и по-настоящему случайные данные (UUID, криптографические ключи, хеши) статистически близки к тому, что смоделировал этот сценарий. Попытка сжать такие данные без предварительной проверки — не нейтральное действие «на всякий случай», а гарантированный небольшой проигрыш и в объёме, и в процессорном времени.
Карта решения
Ниже — не общие советы из интернета, а свод того, что именно показал этот стенд: каждая строка опирается на измеренный в статье выше сценарий и число, а не на репутацию кодека. Строка без опоры на измерение сюда не попала — например, snappy или xz не участвуют в карте вовсе, потому что в матрице кодеков они не измерялись (см. «Матрица кодеков»).
| Данные | Где сжимаем | Узкое место | Сколько раз читают | Что выбрать |
|---|---|---|---|---|
| Поток на сеть с ограниченной полосой — мобильный клиент, WAN, межрегиональная связь | Транспорт, перед отправкой | Полоса канала B ниже скорости кодека V |
1 раз, сразу на другом конце | zstd уровня 3 — выгоден вплоть до измеренного перелома между 1500 и 2500 Мбит/с — прогоны разошлись; gzip уровня 6 — только до перелома между 1000 и 1500 Мбит/с |
| Тот же трафик внутри одного дата-центра или на localhost — гигабиты и десятки гигабит в секунду | Транспорт между сервисами в одной сети | Полосы B* не достичь — она выше скорости любого кодека из матрицы |
1 раз | Не сжимать — CPU на сжатие становится чистой накладной задержкой без выигрыша в времени |
| Архивная копия, ночной бэкап, холодная партиция, которую ещё будут поднимать | Холодное хранение (объектное хранилище) | Число будущих чтений N велико |
Десятки–тысячи | Плотный медленный кодек (gzip уровня 9, zstd/brotli высокого уровня) — на этом корпусе окупается начиная примерно с N ≈ 57 (пример gzip-1 против gzip-9) |
| Промежуточный результат конвейера, который читают и сразу выбрасывают | Холодное хранение / временный файл между стадиями | N = 1, амортизации нет |
1 раз | Быстрый слабый кодек (gzip уровня 1, lz4) — при N = 1 плотный кодек дороже в 8,7 раза |
| Кеш, сериализованный ответ API, горячая страница колоночного хранилища | Онлайн read-heavy путь (пишут редко, читают часто) | Стоимость каждого отдельного чтения на горячем пути | Много относительно числа записей | Жать настолько плотно, насколько позволяет бюджет CPU на записи — ни у одного из четырёх измеренных семейств систематического удорожания разжатия не установлено (gzip и brotli на этом корпусе ускоряются, lz4 неразличим, zstd неоднозначен) |
| Мелкие однотипные сообщения (события, записи лога, ~100 байт) | Сжатие на уровне отдельного сообщения — очередь, брокер | Обычному сжатию неоткуда взять избыточность внутри одного короткого сообщения (ratio 1,0289) | Стрим сообщений, много | Словарное сжатие (zstd + заранее обученный словарь) вместо обычного — ratio 2,2027 против 1,0289 на этом корпусе |
| Аналитическая таблица, колоночная партиция | Хранение колоночного хранилища | Эффективность сжатия зависит от раскладки байт, не только от кодека | Много перечитываний (аналитические запросы) | zstd, а не gzip: колоночная раскладка поднимает его ratio на 23,5%, тогда как ratio gzip растёт лишь на 2,5% |
| Уже сжатые, зашифрованные или по-настоящему случайные данные — JPEG, зашифрованные блобы, UUID, хеши | Любое место в конвейере | Избыточности нет вовсе | Неважно | Не сжимать — все три проверенных кодека увеличили размер (от +0,00012% у lz4 до +0,0077% у gzip) и потратили CPU впустую |
Строки не складываются в единое правило — «сжимай сильнее, если объём большой» здесь не работает нигде: одна и та же полоса канала может окупить zstd и не окупить gzip, один и тот же кодек может быть правильным выбором для холодного архива и неправильным для горячего кеша, а колоночная раскладка одних и тех же значений меняет, какой кодек вообще лучше. Вопрос каждый раз один — что именно ограничивает систему в этой конкретной точке, — а ответ на него у разных мест разный.
Что не воспроизвелось и на что наткнулись
Ни один из пунктов ниже — не оговорка ради соблюдения формальности отчёта. Каждый из них однажды дал неверное число, потребовал отдельной проверки или сломал бы уже готовый замер, если бы его не поймали, и почти во всех случаях причина была бы незаметна без специального поиска. Кто повторит подобные измерения на своей машине и в своём стеке, столкнётся не обязательно с теми же числами, но точно с той же природой ошибки — и именно поэтому каждый пункт здесь предъявлен вместе с тем, что из него следует для чужого замера, а не только для этого.
Первое и самое настойчивое — скорость на этой машине никогда не сходится к одному числу, сколько прогонов ни делай. Даже после отбрасывания прогревочного прогона разброс скорости для сценария matrix — 9–34% по всем 12 кодек-уровням, у остальных сценариев разброс такого же порядка или шире. Вывод не «эта конкретная машина шумная», а «однопроходный бенчмарк сжатия недостоверен в принципе»: без диапазона min–max по нескольким прогонам единственное число скорости с равной вероятностью может оказаться и медианой, и выбросом, и со стороны их не отличить.
Второе — часть этого разброса не случайна, а системна: она объясняется разогревом машины, который проверен отдельной диагностикой, а не предположен по итогу. Четыре эксперимента плюс вторая серия из 12 прогонов на уже прогретой машине показали, что разгон занимает единицы минут и насыщается, а не растёт бесконечно, и что прогретое состояние держится часами и переживает границы процесса. При этом разогрев бьёт по кодекам неравномерно: lz4, измеренный в списке первым, почти не задет, а brotli уровня 1, до которого очередь доходит десятым, разошёлся между прогонами на 158% в исходной серии из трёх холодных прогонов. Отсюда практика этой статьи — первый прогон каждой серии всегда отбрасывается как прогревочный, публикуются только зачётные. Для того, кто меряет свой кодек единственным холодным прогоном по списку из нескольких библиотек: чем позже в списке кодек, тем больше на нём накопленной систематической, а не случайной ошибки.
Третье — формула порога B* = V·(r−1)/r подтвердилась не одинаково хорошо для разных кодеков. У gzip расчётные 1504,85 Мбит/с почти совпали с измеренным переломом между 1000 и 1500 Мбит/с. У zstd расчётные 2957,84 Мбит/с разошлись с измеренным переломом (между 1500 и 2500 Мбит/с, причём два прогона дали разные вилки) на 25–40% — формула переоценила порог. Причина не в ошибке модели как таковой, а в том, чего она не учитывает: постоянные издержки одного HTTP-запроса (round-trip, аллокация и копирование буфера сжатого тела, GC, планирование горутин под WSL2), которые у быстрого zstd (собственный бюджет сжатия около 85 мс) занимают заметно большую долю бюджета, чем у более медленного gzip (около 167 мс). Формула не неверна — она даёт оценку порядка величины, а не точку перелома на живом сетевом пути, и тем хуже работает, чем быстрее сам кодек относительно накладных расходов запроса. Тому, кто выводит подобный порог для своего транспорта, стоит закладывать именно такую систематическую погрешность, а не доверять формуле буквально.
Четвёртое — гипотеза, с которой начинался раздел «онлайн read-heavy», не подтвердилась, и это внесено в статью как есть, а не подправлено по факту. Ожидание было простым: у zstd скорость разжатия почти не зависит от уровня, и именно это должно было отличать его от остальных кодеков. Проверка показала другое: из шести возможных пар уровней различима ровно одна — 1 против 7, — и направление там прямо противоположно гипотезе: разжатие замедляется (903,5 → 787,6 МБ/с), а не остаётся постоянным. При этом пара 1 против 11, которая должна была бы отличаться ещё сильнее, неразличима — потому что у уровня 11 разброс по разжатию непропорционально широк (19,7% от медианы) и перекрывает соседние уровни. Корректная формулировка — «зависимость не установлена из-за разброса измерения», а не «зависимости нет»: разница между ними на первый взгляд незаметна, но вторая формулировка выдаёт желаемое за измеренное, а первая — нет.
Пятое — конкретная версия библиотеки один раз просто упала на грани применимости. zstd.BuildDict в klauspost/compress v1.19.0 паникует делением на ноль (zstd/dict.go:431) при обучении словаря на вырожденно малой выборке — тест обучал словарь на трёх коротких сообщениях. Рабочий сценарий, обучающийся на 20 000 сообщений, этот дефект не задевает, но поймал его не заранее продуманный тест, а отдельный гейт уже после коммита. Урок не про словарное сжатие как метод, а про тестирование вокруг него: граничные случаи обучающей выборки (слишком мало сообщений, слишком короткие, слишком однообразные) стоит проверять отдельно от рабочего размера выборки, а не полагаться на то, что раз большая выборка работает, малая отработает предсказуемо.
Шестое — стандартный http.Client в Go едва не испортил весь замер http-transport молча, без единой ошибки в логе. Транспорт по умолчанию сам добавляет заголовок Accept-Encoding: gzip и прозрачно разжимает ответ на лету, даже когда кодировка выбиралась параметром запроса стенда, а не HTTP-заголовком, — независимо от того, просил клиент об этом явно или нет. Поймал это первый smoke-тест: колонка gzip возвращала wire_bytes, побайтово равный identity, при заметно большем total_ms — то есть сервер честно жал, а клиент так же честно и незаметно всё разжимал обратно ещё до того, как код измерения успевал посчитать байты. Без явного Transport{DisableCompression: true} весь столбец gzip в таблице «горячего транспорта» оказался бы недостоверным — при этом программа отработала бы штатно и вернула правдоподобные, но неверные числа. Для любого, кто меряет байты по проводу через стандартный HTTP-клиент любого языка, это стоит проверить в первую очередь, а не в последнюю.
Седьмое — план ограничивать полосу штатным tc netem/tbf не пережил встречи с реальным ядром. Ядро 5.15.167.4-microsoft-standard-WSL2, на котором держится бэкенд Docker Desktop, не содержит модулей sch_tbf и sch_netem ни встроенными, ни подгружаемыми, а modprobe в контейнере недоступен — команда возвращает Specified qdisc kind is unknown. Это свойство конкретного бэкенда Docker Desktop для Windows, а не общее свойство Linux: на голом ядре штатный путь через tc, вероятно, сработал бы, но здесь это не проверялось. Ограничение полосы пришлось перенести на уровень приложения — и первая версия такого ограничителя с фиксированной порцией 4000 байт сама упёрлась в потолок около 1,8–2,6 Гбит/с независимо от заданной полосы; без перехода на адаптивный размер порции верхние точки sweep выше ~2 Гбит/с вообще не были бы исследованы. Для того, у кого Docker тоже идёт через виртуальную машину, а не нативное ядро Linux, — сетевые примитивы уровня ядра стоит проверять на доступность до того, как план стенда на них опирается, а не после.
Восьмое — «уровень 3 у zstd» оказался не одним и тем же числом даже в пределах одной версии одной библиотеки. klauspost/compress v1.19.0 транслирует номинальный уровень в один из четырёх внутренних пресетов кодировщика, и doc-комментарий к EncoderLevelFromZstd прямо предупреждает: несколько входных значений уровня дают один и тот же результат сжатия. Прямой замер это подтвердил — уровни 3 и 5 на корпусе этого стенда дали побайтово идентичный вывод, 5 079 316 байт. cgo-обвязки поверх libzstd такого квантования не делают и честно перебирают набор параметров, соответствующий именно заданному уровню. Обнаружено это было не изучением документации заранее, а несостыковкой чисел между двумя разными сценариями стенда — «уровень 3» из matrix и «уровень 3» из zstd-impl должны были совпасть по размеру и не совпали. Практическое следствие для любого сравнения чисел между статьями и инструментами: фраза «zstd уровня 3» ничего не говорит без указания конкретной реализации и версии библиотеки — недостаточно даже названия алгоритма и числа уровня, нужна ещё и библиотека.
Источники
- Zstandard: RFC 8878, «Zstandard Compression and the application/zstd Media Type»; эталонная реализация — facebook/zstd (
libzstd). - LZ4: спецификация формата и эталонная C-реализация lz4/lz4; в этом стенде использован Go-порт pierrec/lz4 v4.1.27.
- Brotli: RFC 7932, «Brotli Compressed Data Format»; эталонная реализация — google/brotli; в этом стенде — Go-порт andybalholm/brotli v1.2.2.
- Gzip: RFC 1952, «GZIP file format specification». Алгоритм сжатия внутри — DEFLATE, RFC 1951 — оттуда взято фиксированное окно поиска совпадений 32 КБ, на которое статья ссылается в разделе про колоночное хранение.
- HTTP
Content-Encoding: RFC 9110, «HTTP Semantics», §8.4. - Библиотеки этого стенда, версии зафиксированы в
go.mod: klauspost/compress v1.19.0 (реализации zstd и gzip, использованные в большинстве разделов статьи), pierrec/lz4/v4 v4.1.27, andybalholm/brotli v1.2.2, DataDog/zstd v1.5.7 (cgo-обвязка надlibzstd), valyala/gozstd v1.25.0 (cgo-обвязка надlibzstd). - Код измерений, полные CSV по каждому сценарию и способ калибровки — стенд codecs и соседние модули
dataset/analysis/httpdemoтого же репозитория.
Комментарии