Числа с плавающей точкой плохо поддаются лёгкому сжатию: они не точно представляют большинство реальных чисел, а ошибки округления мешают применять Delta и Frame of Referenceкодирование, при котором из каждого значения вычитается минимальное по вектору, а полученные дельты упаковываются компактнее. Разбираем, как устроено ALPадаптивное lossless-кодирование чисел с плавающей точкой в Parquet, преобразующее значения в целые через десятичное масштабирование, какие проверки оно выполняет при чтении и почему его статус Previewстатус спецификации, при котором формат уже стабилен, но реализация в экосистеме может быть неполной важен для совместимости.
Зачем понадобился ALP
ALP решает три проблемы тяжёлого сжатия: скорость декодирования, произвольный доступ и зависимость от данных, которые мешают распараллеливанию. До ALP единственной не-словарной альтернативой PLAINпростое кодирование, при котором значения записываются подряд без сжатия для FLOAT/DOUBLE был BYTE_STREAM_SPLIT: он не уменьшает размер данных, но может улучшить сжатие при последующем тяжёлом компрессоре.
Применить DELTA_BINARY_PACKEDкодирование, хранящее разности между последовательными целыми значениями к числам с плавающей точкой нельзя: он поддерживает только INT32 и INT64. Кроме того, числа с плавающей точкой не точно представляют большинство реальных чисел, что порождает ошибки округления, препятствующие использованию существующих лёгких кодировок, таких как Delta и Frame of Reference.
ALP кодирует значения пакетами. Каждое значение преобразуется в целое с помощью десятичного масштабирования, управляемого экспонентойПараметр десятичного масштабирования, задающий, на сколько десятичных разрядов сдвигается значение при преобразовании в целое. e и фактором f, затем применяется Frame of Reference (FOR) и битовая упаковка, а неконвертируемые значения хранятся отдельно как исключениязначения, которые не удалось преобразовать без потерь и которые сохраняются отдельно от основной последовательности.
Статус Preview и совместимость
ALP — кодирование с идентификатором 10, предназначенное для FLOAT и DOUBLE. По состоянию на 2026-08-01 оно помечено как Preview. Preview означает, что спецификация стабильна, но реализация в экосистеме (например, parquet-java) может быть неполной; сообщество рекомендует использовать кодирование только при уверенности, что читатель его поддерживает, а писателям — давать opt-in флагпереключатель, который по умолчанию выключен и включается только явным согласием пользователя.
Кодирование выпущено в составе parquet-format 2.14.0 в сентябре 2026. На момент публикации оно уже поддерживалось в Rust-крейте parquet 60.0.0, другие реализации могут добавить поддержку позже. Для читателей это означает: если реализация не знает ALP, прочитать такие данные не получится, поэтому включать кодирование стоит только по явному согласию.
Произвольный доступ не является отсутствующей возможностью: каждое значение кодируется независимо, что обеспечивает произвольный доступ к отдельным значениям и параллельное кодирование/декодирование.
Как выбираются e и f и как значения превращаются в целые
Пара (e, f) выбирается так, чтобы минимизировать размер закодированных данных. экспонента обычно выбирается так, чтобы захватить большинство десятичных цифр в векторе при минимизации исключений, а факторпараметр десятичного масштабирования, задающий, сколько конечных нулей убирается из значения — чтобы убрать как можно больше конечных нулей. Каждый писатель Parquet свободен выбирать их для каждого вектора любым алгоритмом; спецификация приводит пример алгоритма на основе сэмплирования, нацеленного на минимизацию размера закодированных данных.
Значения преобразуются в целые по формуле:
encoded = round(value × 10^e × 10^-f)Затем каждое целое проверяется обратным преобразованием decoded = encoded × 10^f × 10^-e. Значения, которые не проходят round-tripпроверку совпадения исходного значения с результатом обратного преобразования, например 8.0605123 (декодируется в 8.1), сохраняются в массиве исключений. После этого минимальное значение по вектору вычитается из каждого целого, а полученные дельты бит-пакуются.
В примере кодирования выбираются параметры e = 4 и f = 3, значения преобразуются по формуле encoded = round(value × 104 × 10-3), минимальное значение вектора 3335 вычитается из каждого целого, а дельты бит-пакуются по 15 бит.
Почему восстановление остаётся lossless
Обратное преобразование value = encoded × 10^f × 10^-e использует арифметику с плавающей точкой, которая округляет до ближайшего представимого значения и потому может не воспроизвести исходное значение точно. Когда это происходит, ALP сохраняет исходное значение полной точности отдельно как «исключение», сохраняя кодирование lossless. Специальные значения NaN, ±Infinity и -0.0 также сохраняются как исключения.
Проверка выполняется обратным преобразованием: каждый integer проверяется через decoded = encoded × 10^3 × 10^-4, и значения, которые не проходят round-trip, например 8.0605123 (декодируется в 8.1), попадают в массив исключений.
При декодировании сначала распаковываются bit-packed дельты и вычисляются значения по формуле original = (3335 + delta) × 10^3 × 10^-4, затем исключения «патчатся» перезаписью выходного массива в позициях исключений значениями исключений.
Как хранятся исключения
Исключения — это значения, которые не удаётся восстановить точно. Отбор происходит проверкой обратного преобразования: целое декодируется обратно, и значения, не проходящие round-trip, попадают в массив исключений. NaN, ±Infinity и -0.0 тоже хранятся как исключения.
Исключения хранятся непосредственно после массива закодированных значений. При чтении после восстановления значения по формуле проверяются индексы исключений для целевой строки, и при наличии исключения возвращается его значение вместо восстановленного.
Проверки целостности при чтении
При разборе ALP-сегмента выбрасывается DataCorruptionExceptionисключение о повреждении данных в ряде ситуаций. Метаданные не должны заканчиваться раньше заголовка сегмента. Таблица смещений метаданных не должна выходить за пределы сегмента. Смещение вектора не должно выходить за область данных, а сами смещения векторов должны описывать диапазон данных.
Параметры вектора тоже проверяются: exponent не должен превышать max_exponent, factor не должен превышать exponent, exception_count не должен превышать vector_size, bit_width не должен превышать AlpConstants::MAX_BIT_WIDTH, а позиция исключения должна находиться внутри vector_size. При нарушении любого из этих условий чтение прерывается ошибкой.
Битпаковка и порядок битов
Упаковщик выбирается по значению bitLength: для 0 возвращается ZeroBitPackingWriterупаковщик, который не пишет ни одного бита, поскольку все значения нулевые, для 1–8 — соответствующие OneBitPackingWriterупаковщики, каждый из которых пишет значения фиксированной ширины от 1 до 8 бит, а для остальных значений бросается UnsupportedOperationExceptionисключение, сигнализирующее, что запрошенная ширина не поддерживается. Каждый упаковщик накапливает значения в буфере, сдвигая его влево на ширину и добавляя очередное значение, и сбрасывает буфер в поток, когда накопится достаточно значений для целого числа байт.
Завершение работы упаковщика дописывает нули до заполнения буфера и обнуляет ссылку на поток. Общий метод finish вычисляет padding, сдвигает буфер и побайтово пишет его от старшего байта к младшему. Выравнивание по байтам возвращает округлённое вверх до байт число бит.
Порядок битов отличается от deprecated BIT_PACKED: значения упаковываются от младшего бита каждого байта к старшему. Причина — меньше границ слов на little-endian оборудовании при десериализации нескольких байт сразу.
RLE/bit-packing hybrid и длины
RLE/bit-packing hybrid (RLE = 3) хранит данные как последовательность прогоновПоследовательность одинаковых значений, хранимая как bit-packed-run или rle-run в RLE/bit-packing hybrid. — повторов одинаковых значений, — и каждый прогон бывает двух видов: bit-packed-runпрогон, в котором значения упакованы по битам подряд, без повторов либо rle-runпрогон, в котором одно и то же значение повторяется заданное число раз. Два вида нужны потому, что данные бывают и с длинными сериями одинаковых значений, и с разнородными: для серий выгоднее хранить само значение и число повторов, для разнородных — упакованные по битам значения. В bit-packed-run заголовок кодируется как varint-encode(<bit-pack-scaled-run-len> << 1 | 1), причём значения всегда упаковываются кратно 8, поэтому хранится число значений, делённое на 8. В rle-run заголовок — varint-encode((rle-run-len) << 1), а repeated-value записывается фиксированной шириной round-up-to-next-byte(bit-width).
Длина prepend-ится не всегда — это зависит от вида страницы и вида RLE-кодированных данных. Для Data page v1 длина добавляется для definition levels, repetition levels и boolean values, но не для dictionary indices. Для Data page v2 длина не добавляется для definition levels, repetition levels и dictionary indices, но добавляется для boolean values.
Как writer выбирает кодировку
В DefaultV2ValuesWriterFactory методы getEncodingForDataPage() и getEncodingForDictionaryPage() не содержат условий: для страницы данных всегда выбирается RLE_DICTIONARYкодирование, при котором значения заменяются ссылками на словарь, а сами ссылки упаковываются прогонами и битовой упаковкой, для страницы словаря — PLAIN. Для FLOAT и DOUBLE выбор ALP в этом классе не выполняется: getFloatValuesWriter и getDoubleValuesWriter проверяют только parquetProperties.isByteStreamSplitEnabled(path) и в зависимости от этого создают ByteStreamSplitValuesWriter (Float/Double) либо PlainValuesWriter, после чего оборачивают результат через dictWriterWithFallBack с этими фиксированными кодировками.
Проверка isByteStreamSplitEnabled для FLOAT и DOUBLE срабатывает при любом режиме, кроме NONE, а для INT32/INT64/FIXED_LEN_BYTE_ARRAY — только при EXTENDED.
Для BOOLEAN словарное кодирование отключено: getBooleanValuesWriter сразу возвращает RunLengthBitPackingHybridValuesWriter, а dictionaryWriter для BOOLEAN бросает IllegalArgumentException.
Настройки через ParquetProperties.Builder
Словарное кодирование включается/выключается глобально методом withDictionaryEncoding(boolean), который задаёт значение по умолчанию, и по отдельной колонке методом withDictionaryEncoding(String columnPath, boolean enableDictionary), который пишет значение для конкретного пути.
BYTE_STREAM_SPLIT настраивается тремя методами: withByteStreamSplitEncoding(String columnPath, boolean) задаёт для колонки режим EXTENDED или NONE, а withExtendedByteStreamSplitEncoding(boolean) задаёт по умолчанию EXTENDED или NONE. Режим EXTENDEDрежим BYTE_STREAM_SPLIT, при котором кодирование применяется также к INT32, INT64 и FIXED_LEN_BYTE_ARRAY: в этом случае кодирование доступно не только для чисел с плавающей точкой, но и для целочисленных типов и байтовых массивов фиксированной длины.
Версия формата задаётся withWriterVersion(WriterVersion version), доступны значения PARQUET_1_0("v1") и PARQUET_2_0("v2"). От неё зависит создание ColumnWriteStore: для PARQUET_1_0 возвращается ColumnWriteStoreV1, для PARQUET_2_0 — ColumnWriteStoreV2.
Значения по умолчанию задаются в конструкторе Builder: enableDict получает DEFAULT_IS_DICTIONARY_ENABLED, а byteStreamSplitEnabled — FLOATING_POINTРежим BYTE_STREAM_SPLIT, при котором кодирование применяется только к FLOAT и DOUBLE., если DEFAULT_IS_BYTE_STREAM_SPLIT_ENABLED истинно, иначе NONE.
Метода для включения ALP среди настроек Builder нет — есть только withValuesWriterFactory для подстановки фабрики ValuesWriter.
Связь Encoding с reader/writer и словарь
ALP определён как Adaptive Lossless floating-Point с номером 10 и поддерживает FLOAT и DOUBLE. Кодирование не опирается на словарь: при чтении значения восстанавливаются из десятичного масштабирования, FOR и битовой упаковки, а не подстановкой ссылок на словарные записи. Для RLE_DICTIONARY, напротив, значение извлекается по ссылке из переданного словаря.
Настройки страниц
withPageSize задаёт порог размера страницы в байтах: значение сохраняется в поле pageSize и затем в pageSizeThreshold, которое передаётся в конструкторы писателей уровней как параметр. withPageValueCountThreshold задаёт порог по числу значений. withMinRowCountForPageSizeCheck и withMaxRowCountForPageSizeCheck задают минимальное и максимальное число строк между проверками размера страницы. estimateRowCountForPageSizeCheck задаёт режим оценки следующей проверки размера; в toString этот режим отображается как "estimated" или "constant".
Пограничные случаи и компромиссы
ALP достигает высокого сжатия для «десятичных» данных — денежных значений, показаний датчиков — оставаясь полностью lossless. В примере для вектора из 1024 значений ALP использует 1920 байт для битово упакованных дельт плюс 13-байтовый заголовок вектора и место для исключений, тогда как PLAIN использует 8192 байта для тех же 1024 значений.
ALP не подходит для данных с широким диапазоном экспонент или большим числом значащих цифр, таких как векторные эмбеддинги, которые обычно покрывают весь диапазон чисел с плавающей точкой. Для таких данных можно продолжать использовать PLAIN или BYTE_STREAM_SPLIT с ZSTD.
По сравнению с PLAIN+ZSTD и BYTE_STREAM_SPLIT+ZSTD пользователи могут ожидать, что ALP декодируется в 10 раз быстрее и извлекает отдельные значения в тысячи раз быстрее, при чуть более низкой степени сжатия и чуть более быстром сжатии.
Что из этого следует на практике
- ALP — кодирование с идентификатором 10 для FLOAT и DOUBLE, выпущенное в parquet-format 2.14.0 в сентябре 2026 и помеченное как Preview по состоянию на 2026-08-01. Пока реализация в экосистеме может быть неполной, включать его стоит только тогда, когда известно, что читатель его поддерживает.
- Кодирование lossless достигается не точностью арифметики, а механизмом исключений: любое значение, не проходящее round-trip, сохраняется в исходной точности и «патчится» при чтении. NaN, ±Infinity и -0.0 всегда попадают в исключения.
- Каждое значение кодируется независимо, поэтому произвольный доступ и параллельная обработка доступны — в отличие от подходов, где чтение одного значения требует декодирования всей страницы.
- Выбор пары (e, f) — зона ответственности писателя: спецификация даёт лишь пример алгоритма на основе сэмплирования, нацеленного на минимизацию размера. От этого выбора зависит доля исключений и итоговый размер страницы.
- В DefaultV2ValuesWriterFactory и ParquetProperties.Builder нет способа включить ALP: для FLOAT/DOUBLE там выбирается между BYTE_STREAM_SPLIT и PLAIN. Писателям рекомендуется давать opt-in флаг.
- ALP выгоден на «десятичных» данных и не подходит для данных с широким диапазоном экспонент или большим числом значащих цифр — там остаются PLAIN или BYTE_STREAM_SPLIT с ZSTD.
Где смотреть в коде
- alp.cpp: ThrowAlpMetadataBeforeHeader
- ParquetProperties.java: withDictionaryEncoding
- ParquetProperties.java: getWriterVersion
- ParquetProperties.java: Builder
- ParquetProperties.java: withMaxRowCountForPageSizeCheck
- BitPacking.java: finish
- Encoding.java: usesDictionary
- BitPacking.java: getBitPackingWriter
Источники
- parquet.apache.org/blog/2026/09/22/alp-adaptive-lossless-floating-point-encoding-in-apache-parquet/
- parquet.apache.org/docs/file-format/data-pages/encodings/
- github.com/…/alp.cpp
- github.com/…/BitPacking.java
- github.com/…/DefaultV2ValuesWriterFactory.java
- github.com/…/Encoding.java
- github.com/…/ParquetProperties.java