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

Типизированный вывод и 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без округления. Точность 118, масштаб 0–точность: значения хранятся в INT64, поэтому 18 цифр — потолок (TDC194)
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 → булево, как и колонка <mix flag=>. И, что удобно, missing сам делает колонку nullable (qty стала OPTIONAL).

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

генератортип колонки
timeseries, patternцелое, с decimals — дробное
runningтип той колонки, которую называет of=
statcount → целое; mean, median, stddev → дробное; sum, min, max → тип источника
formulaцелое или дробное, если задан decimals=, иначе текст
file с read="quantile"дробное, с decimals="0" — целое
increment / decrementцелое, а если value= или step= дробные — дробное
<mix>общий тип, если каждая ветка — это один <gen>, выводящий этот тип; при любом расхождении, а также если в ветке есть буквальный текст — string. Колонка <mix flag=> — булева

Строка про формулу выбивается намеренно. expr="A + 1" — целое, expr="A / 2" — уже нет, а expr="A > 5 ? over : under" — вообще СЛОВО. Поэтому decimals= — единственный честный признак того, что лежит в колонке. Пишите decimals="0", когда ответ целый и нужна целочисленная колонка.

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

Квантильное чтение — единственная строка таблицы, где генератор file вообще получает тип, и по обычной причине: read="quantile" требует, чтобы файл был числовым, иначе прогон откажется. Значит, колонка — число по построению, и это факт о генераторе, а не догадка по значениям. Обычное чтение файла по-прежнему текст: файл — это мешок с тем, что в нём лежит.

Какое именно число, решает только конфигурация — этот слой файл не открывает. decimals="0" единственный обещает целые значения; без него точность приходит из источника и может быть дробной, поэтому ответ — дробное число. Это безопасное направление: дробное вмещает любое значение такой колонки, и 31, записанное как 31.0, ничего не теряет, тогда как текст теряет сам тип:

<sequence name="Amount"><gen type="file" src="amounts.txt" read="quantile"/></sequence>
<sequence name="Age"><gen type="file" src="ages.txt" read="quantile" decimals="0"/></sequence>

amount выйдет DOUBLE, ageINT64. Если нужен более узкий тип, укажите type= руками.

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

  • 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 в этом пути, а без -o прогон напечатает текст. Запись в несколько потоков тоже требует файла: координатору надо знать, куда легла каждая группа. Число потоков задаётся флагом --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.

Этот порядок файл ещё и объявляет в футере (column_orders). Без такого объявления формат требует, чтобы читатель игнорировал границы, какими бы верными они ни были, — Java-читатели просто выбрасывают min/max для строк. TDC писал верные границы, которыми никому не разрешалось пользоваться; теперь рядом с ними пишется и объявление.

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

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

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

Колонке с уникальными значениями словарь только навредил бы, поэтому TDC его там не применяет. Правило простое: словарь берётся, когда разных значений вдвое меньше числа строк или ещё меньше, — кроме bool, которому словарь не достаётся никогда. Булево и так стоит бит, словарь мог бы только добавить страницу. Два разных значения на 2000 строк — это сильно меньше половины, и всё равно остаётся один PLAIN, тогда как соседние int64 и string получают RLE_DICTIONARY.

Сжатие

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

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

Сжатие выбирается по колонке и только когда оно выигрывает. У snappy есть служебные байты, и на совсем маленькой странице они стоят больше, чем экономят — такую колонку TDC оставляет несжатой. Колонка города из пяти значений на 50 000 строк идёт именно так: 18 839 байт на входе, 18 839 на выходе, кодек UNCOMPRESSED. Колонка uuid на тех же строках — наоборот, snappy экономит 209 КБ из 2 МБ.

Решение принимается по странице, а не по куску, поэтому кусок, сжимающийся почти в ничто, может выйти на пару байт больше, чем вошёл, — намеренный случай: 99 байт на входе, 101 на выходе. Два байта на файле, который и так втрое меньше исходного: знать полезно, избегать незачем.

Реализовано собственным кодом 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 каждого языка см. в Языковых привязках.

Словари (MAP)

Колонка может держать словарьtype="{}int64" это словарь «текст → int64». В ячейке лежат пары ключ:значение, разделённые так же, как элементы списка:

<data name="gauges" type="{}int64|null">cpu:${{Cpu}},mem:${{Mem}}</data>
./run metrics.tdc -o metrics.parquet
host    STRING  REQUIRED
gauges  MAP<STRING, INT64>  REQUIRED

{"host":"db-1",  "gauges":{"cpu":74,"mem":null}}
{"host":"db-1",  "gauges":{"cpu":65,"mem":43}}
{"host":"web-1", "gauges":{"cpu":15,"mem":15}}
{"host":"web-1", "gauges":{"cpu":10,"mem":12}}
{"host":"web-2", "gauges":{"cpu":6,"mem":null}}

Читатель получает настоящий словарь, а не строку, которую надо разбирать самому: gauges['cpu'] — это запрос, а missing= на генераторе значения приходит внутрь честным null.

Ключ всегда текстовый. Parquet не разрешает null в ключе, ячейка и так приходит текстом, а второй параметр типа удвоил бы синтаксис ради преобразования, которого никто не просил. Поэтому {}T читается как «словарь из текста в T», и выбирать больше нечего. |null относится к ЗНАЧЕНИЮ — ровно так же, как относится к элементу списка.

Как писать ячейку

  • Ключ — всё до первого :, значение — остальное. Значит, в значении двоеточия допустимы (start:12:30:00 — одна пара), а в ключе нет.
  • Пустая ячейка — пустой словарь, то же правило, что и у списка.
  • Три вещи отклоняются, а не угадываются, потому что каждая оставила бы вас со словарём, где тихо не хватает записи: кусок вообще без :, пустой ключ и ключ, который повторяется внутри одной строки (читатели расходятся в том, какой из двух побеждает, а некоторые выбрасывают словарь всей строки).

Чего пока нет

  • Сжатие zstd / brotli — snappy есть, этих пока нет.
  • Словарь для чисел с плавающей точкой работает, но выигрыш обычно меньше — повторов среди дробных немного.
  • Списки списков[]int64 и {}int64 работают, а [][]int64 нет: понадобился бы второй разделитель, и ни одна его форма пока не заслужила своего места.
  • Геометрические типы — добавляются по одному, каждый лишь ярлык поверх тех же байтов.
примечание

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

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