Типизированный вывод и 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 # текст, ровно как раньше
Вот что увидит тот, кто откроет файл — схема (тип на колонку), а затем строки:
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 — без округления. Точность 1–18, масштаб 0–точность: значения хранятся в INT64, поэтому 18 цифр — потолок (TDC194) |
uuid | UUID как 16 байт | канонический вид |
json | JSON | как есть |
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, а в float16 —
0.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 вычислил сам:
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= |
stat | count → целое; 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 (007 → 7). Если
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, age — INT64. Если нужен более узкий тип, укажите type= руками.
Два случая, когда вывод сознательно пропускается
dateбезformat="YYYY-MM-DD". По умолчанию дата печатается как05/25/1996, а это не ISO — объявлять такое датой было бы нечестно.maskилиcaseна генераторе. Они переписывают текст, и число уже не число.
В обоих случаях поставьте type= руками, если знаете, что делаете.
Когда значение не подходит под тип
TDC никогда не пишет испорченный файл — он останавливается и говорит, где именно:
tdc: column "n", row 1: "abc" is not an integer (int64)
Опечатку в самом имени типа валидатор поймает ещё до начала генерации (код
TDC194).
Большие объёмы — row-group'ы
Файл пишется row-group'ами (по 50 000 строк): группа собирается, пишется и освобождается, поэтому память не растёт с числом строк. 120 000 строк — это три группы, и читатель может пропускать целые группы, не разбирая файл от начала до конца. Это то же потоковое поведение, что держит большие выгрузки на ровной памяти.
Эти же группы дают многопоточность. Байты группы не зависят от того, где она лежит в файле — размеры записаны в заголовках страниц, а все смещения собраны в оглавлении, — поэтому потоки собирают группы независимо, а в конце координатор складывает их подряд и дописывает одно оглавление с поправленными смещениями.
Работа делится по группам, а не по строкам — разрежь группу пополам, и получились бы группы, каких однопоточный запуск не делает. Поэтому вывод совпадает байт в байт при любом числе потоков. На миллионе строк:
--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>
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 строк:
| колонка | разных значений | словарь | размер |
|---|---|---|---|
city | 5 | да | 18 КБ |
status | 3 | да | 12 КБ |
uuid | 50 000 | нет — не окупился | 781 КБ |
Колонке с уникальными значениями словарь только навредил бы, поэтому TDC его там не
применяет. Правило простое: словарь берётся, когда разных значений вдвое меньше числа
строк или ещё меньше, — кроме bool, которому словарь не достаётся никогда. Булево и
так стоит бит, словарь мог бы только добавить страницу. Два разных значения на 2000 строк —
это сильно меньше половины, и всё равно остаётся один PLAIN, тогда как соседние int64 и
string получают RLE_DICTIONARY.
Сжатие
Страницы сжимаются алгоритмом snappy — стандартом для Parquet, который понимает любой читатель. На реальном наборе из 50 000 строк и 14 колонок:
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)
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).
Сам датафрейм:
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>
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).
Смотрите также
- Форматы вывода (CSV, JSON, SQL…) — текстовая сторона того же блока вывода и где кусается синтаксис каждого формата.
- Вывод и оформление —
<block>,<line>и<data>целиком. - Большие выгрузки и потоковый вывод — row-group'ы,
--jobsи ровная память при любом размере. - CLI —
-o,--jobs,--engine. - Маски и регистр —
mask/case, которые выключают вывод типа. - Языковые привязки — чтение и запись из всех пяти: TypeScript, Python, Java, C# и Rust.