Перейти к основному содержимому

Типизированный вывод и Parquet

Пригодится, когда файл идёт в анализ — pandas, DuckDB, Spark, хранилище данных — и нужно, чтобы он нёс настоящие типы колонок и настоящий NULL, а не просто текст, о смысле которого читателю приходится догадываться. Дайте колонкам <data> имена, назовите файл с расширением .parquet — и TDC запишет двоичный типизированный файл, без внешних библиотек и без лишних флагов.

Весь наш вывод до сих пор был текстом: CSV, JSON, SQL. Для человека и для всего, что читает символы, это отлично. А для анализа данных у текста две проблемы, которые Parquet решает.

  • Типов нет. В CSV всё — строка. Дата-сайентист грузит файл и заново гадает, где число, где дата, где просто текст — и угадывает неправильно: 007 превращается в 7, и номер документа тихо испорчен.
  • NULL не выразить. Пустое место между двумя запятыми — это «пустой текст» или «значения не было»? missing выдаёт пустую строку, и разница теряется.

Parquet — это колоночный двоичный формат, на который опираются все аналитические инструменты. У каждой колонки есть настоящий тип и настоящий NULL, а файл открывается одной строкой — pd.read_parquet("data.parquet") — и чинить в нём ничего не надо.

примечание

Вывод в примерах ниже — иллюстративный: точные значения зависят от версии ядра и сида. Важна форма: строка схемы на каждую колонку и то, где появляется настоящий null.

Как типизированный файл разложен на диске. Схема.
  • Arow group: срез строк, который собирается, пишется и отпускается
  • Bпервая колонка этого среза, лежащая отдельно
  • Cвторая колонка
  • Dтретья — читатель, которому нужна одна колонка, трогает только её куски

Как включить

Нужно две вещи: пометить, какие теги <data> — колонки, и назвать файл .parquet.

Колонка — это <data> с атрибутом name. Без name тег остаётся обычным текстом оформления и в файл не попадает. Тип задаётся атрибутом type.

<block>
<line>
<data name="id" type="int64">${{Id}}</data>
<data name="reading" type="int64">${{Reading}}</data>
<data name="is_outlier" type="bool">${{IsOutlier}}</data>
<data name="city" type="string">${{City}}</data>
<data name="amount" type="int64|null">${{Amount}}</data>
</line>
</block>

Формат выбирается по расширению файла — никаких новых флагов:

tdcv2 data.tdc -o data.parquet # двоичный типизированный файл
tdcv2 data.tdc -o data.csv # текст, ровно как раньше

Вот что увидит тот, кто откроет файл — схема (тип на колонку), а затем строки:

./run data.tdc -o data.parquet (схема + первые строки)
id          INT64       REQUIRED
reading     INT64       REQUIRED
is_outlier  BOOLEAN     REQUIRED
city        BYTE_ARRAY  REQUIRED  {"type":"STRING"}
amount      INT64       OPTIONAL

{"id":1,"reading":45, "is_outlier":false,"city":"Moscow", "amount":2143}
{"id":2,"reading":54, "is_outlier":false,"city":"Moscow", "amount":2328}
{"id":3,"reading":42, "is_outlier":false,"city":"Kazan", "amount":5275}
{"id":4,"reading":42, "is_outlier":false,"city":"Samara", "amount":null}
{"id":5,"reading":540,"is_outlier":true, "city":"Samara", "amount":5787}
{"id":6,"reading":53, "is_outlier":false,"city":"Kazan", "amount":3308}

Две детали, на которых стоит остановиться. amount в четвёртой строке — настоящий null (колонка OPTIONAL), а не пустая строка: это наш missing, который наконец приехал как положено. А is_outlier — настоящий BOOLEAN: колонка-метка anomaly_flag, то есть готовый размеченный датасет для проверки детектора аномалий.

Какие типы можно писать

type=Что этоИз какого текста читается
boolистина / ложьtrue/false, 1/0
int32целое 32 бита-42
int64целое 64 бита9007199254740993 — точно
doubleдробное 8 байт3.14, 1e3
stringтекст UTF-8как есть
dateкалендарная дата2020-05-14
timestampмомент времениISO-8601
decimal(p,s)точное десятичное (деньги)123.45без округления
uuidUUID как 16 байтканонический вид
jsonJSONкак есть
floatдробное 4 байта3.14 — вдвое меньше double
float16дробное 2 байта3.14 — точность ~3 знака
enumтекст-перечислениеRED — строка, но с ярлыком
uint8/16/32/64целое без знака255 — отрицательное отвергнет

Добавьте \|null после типа, чтобы сделать колонку допускающей NULL: type="int64\|null". Без этого пустое значение — ошибка, и это бесплатная проверка качества: если колонка не должна быть пустой, TDC об этом скажет.

decimal никогда не округляет молча

decimal(18,2) со значением 123.456 — это ошибка, а не потерянная копейка.

Узкие дробные теряют точность сознательно

float и float16 намеренно отказываются от точности — в этом их смысл, они занимают меньше места. 0.1 в float станет 0.100000001490116, а в float160.0999755859375. Значение в файле ровно такое, каким его увидит читатель (TDC округляет сразу, чтобы статистика не описывала числа, которых в файле нет). Выход за диапазон (1e40 для float, 100000 для float16) — ошибка, а не тихая бесконечность.

Беззнаковые целые

uint64 вмещает числа до 18 446 744 073 709 551 615 — больше, чем int64. Отрицательное значение в такой колонке — ошибка.

Тип можно не писать — TDC выведет его сам

Движок знает, какой генератор породил колонку, поэтому в большинстве случаев type= не нужен. Вот конфиг, где нет ни одного type=:

<sequence name="Id"><gen type="increment" value="1"/></sequence>
<sequence name="Price"><gen type="number" value="1..999" decimals="2"/></sequence>
<sequence name="Qty"><gen type="number" value="1..99" missing="0.4"/></sequence>
<sequence name="Born"><gen type="date" range="1990-01-01..2000-12-31" format="YYYY-MM-DD"/></sequence>
<sequence name="Key"><gen type="template" value="common.id.uuid"/></sequence>
<sequence name="R"><gen type="number" value="10..20" anomaly="0.4" anomaly_flag="Flag"/></sequence>
...
<data name="id">${{Id}}</data>
<data name="price">${{Price}}</data>
<data name="qty">${{Qty}}</data>
<data name="born">${{Born}}</data>
<data name="key">${{Key}}</data>
<data name="flag">${{Flag}}</data>

Что TDC вычислил сам:

./run inferred.tdc -o inferred.parquet (выведенная схема)
id     INT64                REQUIRED
price  DOUBLE               REQUIRED
qty    INT64                OPTIONAL
born   INT32                REQUIRED  {"type":"DATE"}
key    FIXED_LEN_BYTE_ARRAY REQUIRED  {"type":"UUID"}
flag   BOOLEAN              REQUIRED

{"id":1,"price":230,"qty":63, "born":"1996-05-25","key":"e96b21bc-...","flag":true}
{"id":2,"price":589,"qty":null,"born":"2000-05-01","key":"85caccad-...","flag":false}

Правила простые: number без decimals → целое, с decimals → дробное; счётчик increment → целое; common.id.uuid → UUID; колонка-метка anomaly_flag → булево. И, что удобно, missing сам делает колонку nullable (qty стала OPTIONAL).

Порядок такой: явный type= → вывод из генератора → текст. TDC никогда не угадывает тип по самим значениям — именно это и портит данные в CSV (0077). Если TDC не уверен, колонка остаётся строкой: строка ничего не ломает.

Два случая, когда вывод сознательно пропускается

  • date без format="YYYY-MM-DD". По умолчанию дата печатается как 05/25/1996, а это не ISO — объявлять такое датой было бы нечестно.
  • mask или case на генераторе. Они переписывают текст, и число уже не число.

В обоих случаях поставьте type= руками, если знаете, что делаете.

Когда значение не подходит под тип

TDC никогда не пишет испорченный файл — он останавливается и говорит, где именно:

./run bad.tdc -o bad.parquet
tdc: column "n", row 1: "abc" is not an integer (int64)

Опечатку в самом имени типа валидатор поймает ещё до начала генерации (код TDC194).

Большие объёмы — row-group'ы

Файл пишется row-group'ами (по 50 000 строк): группа собирается, пишется и освобождается, поэтому память не растёт с числом строк. 120 000 строк — это три группы, и читатель может пропускать целые группы, не разбирая файл от начала до конца. Это то же потоковое поведение, что держит большие выгрузки на ровной памяти.

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

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

./run big.tdc -o big.parquet (--jobs 1 / 4 / 8)
--jobs 1    6.51 s
--jobs 4    2.51 s
--jobs 8    2.18 s      <- файлы всех трёх запусков идентичны

Одно условие: нужен настоящий файл (-o). В стандартный вывод Parquet пишется в один поток — координатору надо знать, куда легла каждая группа. Число потоков задаётся флагом --jobs, если хотите; байты в любом случае одни и те же.

Списки

Колонка может держать список значений, а не одно: type="[]int64" либо просто repeat на генераторе — тогда тип выведется сам. Пустые списки и null внутри списка записываются честно.

<data name="scores" type="[]int64">${{Scores}}</data>
./run lists.tdc -o lists.parquet
scores  INT64  OPTIONAL  (repeated)

{"scores":[45,52,61]}
{"scores":[]}
{"scores":[70,null,55]}

Быстрые запросы — статистика по колонкам

Для каждой колонки TDC записывает минимум, максимум и количество NULL. Это позволяет читателю пропускать целые блоки: на запрос вроде amount > 500 он смотрит на максимум блока и, если тот меньше, не разбирает блок вовсе.

Сравнение идёт по правилам формата, а не JavaScript: строки сравниваются по своим байтам UTF-8. В ASCII заглавная буква стоит раньше строчной, поэтому "Apple" < "apple" < "zebra", а любой не-ASCII текст сортируется после всего ASCII — это устойчивый переносимый порядок, с которым согласен любой читатель Parquet.

Повторяющиеся значения хранятся один раз

Если в колонке мало разных значений — города, статусы, категории — TDC записывает их словарём: сам список один раз, а каждая строка — маленький номер, указывающий в него. Решение принимается само, по данным. На 50 000 строк:

колонкаразных значенийсловарьразмер
city5да18 КБ
status3да12 КБ
uuid50 000нет — не окупился781 КБ

Колонке с уникальными значениями словарь только навредил бы, поэтому TDC его там не применяет. Правило простое: словарь берётся, когда разных значений вдвое меньше числа строк или ещё меньше.

Сжатие

Страницы сжимаются алгоритмом snappy — стандартом для Parquet, который понимает любой читатель. На реальном наборе из 50 000 строк и 14 колонок:

ls -lh (без сжатия vs. сейчас)
no compression, no dictionary:  5.70 MB
now:                            1.99 MB      <- почти втрое меньше

Сжатие выбирается по колонке и только когда оно выигрывает. У snappy есть служебные байты, и на совсем маленькой странице они стоят больше, чем экономят — такую колонку TDC оставляет несжатой. Файл никогда не растёт от попытки его уменьшить.

Реализовано собственным кодом TDC, без сторонних библиотек — и не только ради зависимостей: две реализации snappy могут выдать разные (одинаково верные) байты на одних данных, и именно общий кодировщик позволяет всем пяти реализациям давать побайтово одинаковые файлы на одной версии.

Чтение обратно в pandas

Вся выгода — на стороне читателя. Одна строка, и датафрейм уже с правильными dtype и настоящим NaN там, где в файле был null — чистить нечего:

import pandas as pd

df = pd.read_parquet("data.parquet")
print(df.dtypes)
python read.py (dtypes)
id             int64
reading        int64
is_outlier      bool
city           object
amount        float64
dtype: object

id и reading — целые, is_outlier — настоящий булев тип, city — текст, а amount (nullable-колонка) возвращается как float64, чтобы держать NaN в пропущенных строках (попросите pandas включить nullable-бэкенд через pd.read_parquet(..., dtype_backend="numpy_nullable"), чтобы сохранить nullable Int64). Сам датафрейм:

python read.py (df.head)
   id  reading  is_outlier    city  amount
0   1       45       False  Moscow  2143.0
1   2       54       False  Moscow  2328.0
2   3       42       False   Kazan  5275.0
3   4       42       False  Samara     NaN
4   5      540        True  Samara  5787.0

amount в четвёртой строке — NaN, а не пустая строка: null пережил обход туда-обратно. Полное описание библиотечного API каждого языка см. в Языковых привязках.

Чего пока нет

  • Сжатие zstd / brotli — snappy есть, этих пока нет.
  • Словарь для чисел с плавающей точкой работает, но выигрыш обычно меньше — повторов среди дробных немного.
  • MAP и вложенные структуры внутри колонки — списки уже есть (см. repeat), а вот MAP и списки списков не поддержаны.
  • Геометрические типы — добавляются по одному, каждый лишь ярлык поверх тех же байтов.
примечание

float, float16 и enum раньше числились здесь как нереализованные — теперь они работают и дают правильные логические типы (FLOAT, FLOAT16, ENUM).

Смотрите также