Большие объёмы и стриминг
По умолчанию TDC генерирует прямо на диск и работает на любом объёме — хоть миллиарды строк. Память при этом не растёт с числом строк: каждая строка считается «на лету» по своему номеру, а не хранится в массиве. Никакой специальной настройки не нужно — так работает обычный запуск. Несколько форм конфига — исключение; они перечислены в разделе Какой движок запустит ваш конфиг.
Примеры вывода на этой странице иллюстративные и могут отличаться от версии к версии ядра; там, где страница называет точную цифру («ровно 70/30», «стабильные 128 МБ»), смотрите на форму результата, а не на конкретные байты.
- Aдержать все строки в памяти: расход растёт вместе с прогоном
- Bпотоково: за раз одно окно, поэтому расход не растёт, сколько бы строк ни было
Два дисковых движка — программа выбирает сама
Под «диском» работают два движка, и TDC выбирает между ними по вашему конфигу:
-
Быстрый потоковый движок — берётся почти для всего. Ленивый, многопоточный (см.
--jobs), память O(числа полей). Точные проценты,parent-зависимости,<mix>,<distinct>— всё «на лету». -
Точный движок на диске — для обещания про готовый столбец, а не про текущую строку: env-уровневая группа
<uniq>,uniq="true"на составной последовательности или на счётчике,parent, у которого родитель — не текстовая последовательность, и взвешенныйadvanced_regex—(?%{…}). Результат он гарантирует точно, и платит за это проверкой каждого набора значений и починкой тех немногих, что повторились. Память остаётся ограниченной, пока повторов меньше предохранителя починки; за ним прогон передаёт себя in-memory движку, и память снова растёт вместе сcount. Смотрите таблицу ниже — рассуждать надо о предохранителе, а не о числе строк.Уникальность — обещание про готовый набор данных, а не про отдельную строку, и решить его построчно нельзя. Рабочий видит только свой диапазон строк и не отличит дубликат за его пределами от значения, которого никогда не видел, — поэтому его об этом и не спрашивают. Раскладка считается ОДИН раз, до запуска рабочих, и раздаётся им готовой; рабочие лишь выкладывают доставшиеся строки. Поэтому группа
<uniq>на уровне<env>делится по--jobsкак всё остальное — во всех пяти реализациях.А
uniq="true"на последовательности — нет, и не может: он переставляет генераторы внутри одной составной колонки, чего рабочий, решающий строку сам по себе, воспроизвести не в состоянии. Эта форма остаётся на одном потоке и говорит об этом.
У дискового режима есть и третье назначение, и как раз о нём стоит знать: семь форм
конфига отправляют прогон обратно на маленький in-memory движок, где память растёт вместе
с count. Одна из них — самый обычный способ написать uniq. Какой движок запустит ваш
конфиг перечисляет все семь.
Выбор детерминированный — по содержимому конфига, а не по железу, так что один и тот же конфиг даёт один и тот же результат на любой машине (воспроизводимость между машинами — базовая гарантия TDC).
Какой движок запустит ваш конфиг
mode="disk" просит ограниченную память. Получает он её не всегда. Сначала TDC читает
конфиг, и семь форм уводят прогон на маленький in-memory движок, память которого
растёт вместе с числом строк. Проверяются они в таком порядке.
| Форма | Почему это нельзя считать потоком |
|---|---|
value у template, в который подставляется поле — common.vehicle.model.${{Brand}} | Адрес неизвестен, пока у соседнего столбца нет значения, поэтому его приходится разрешать на каждой строке по другим последовательностям. |
weight= и row= на одном генераторе file | Чтобы взвесить связанный выбор строки до точной квоты, нужны итоги по всему файлу заранее. Один weight= сам по себе потоковый — в память уходит только пара. |
uniq="true" на одном разыгрываемом столбце, отдельно или рядом с литеральным текстом | Розыгрыш идёт без возвращения, поэтому и пул значений, и множество уже занятых охватывают весь столбец. |
type="http" — сетевой вызов | Он не воспроизводим и не синхронен и разрешается отдельным асинхронным проходом, уже после того как собран остальной реестр. |
percent= внутри ветки <switch>, совпадающей с несколькими ключами — is="US|CA|MX" — или внутри <default> | Доля — это квота по собственным строкам ветки, а эти строки суть объединение подмножеств либо остаток после всех прочих веток. Ни то ни другое нельзя пронумеровать по одной строке за раз. |
| Колонка, производная от другой колонки — нарастающий итог, статистика, дата, отмеренная от другой колонки, формула | Каждая читает колонку, которая должна существовать раньше, и потоковый построитель отказывает по имени. |
Голый parent="Имя" без значения | Он сужает колонку до строк, где родитель что-то произвёл, а это неизвестно без всей родительской колонки. |
Ещё одна форма попадает на тот же движок, не попав в этот список: тело пака со своим
<valid>. Отбросить выпавшую строку и переиграть её — решение про весь столбец, у
которого нет построчного вида, поэтому потоковый построитель отказывается от неё по
имени, а отказ уводит сюда так же, как любой другой.
Всё остальное, что спрашивает про готовый столбец, идёт на точный движок на диске, а прочее считается потоково.
Поэтому uniq попадает на два разных движка — смотря как он написан:
Как написан uniq | Движок | Память |
|---|---|---|
uniq="true" на одном разыгрываемом столбце — text, number, date, template | in-memory | растёт вместе с count |
uniq="true" на столбце из разыгрываемой части и литералов <data> | in-memory | растёт вместе с count |
uniq="true" на счётчике | точный на диске | растёт с count |
uniq="true" на составной последовательности (именованные поля <gen>) | точный на диске | ограничена, пока повторов немного |
env-уровневая группа <uniq> | точный на диске | ограничена, пока повторов немного |
Последние две ограничены, и это доказано запретом памяти, а не наблюдением за ней: каждому прогону ниже жёстко зажали кучу, и он обязан был уложиться.
env-уровневая группа <uniq> 4 800 000 строк прошёл с кучей, зажатой до 256 МБ
составной uniq 4 800 000 строк прошёл с кучей, зажатой до 256 МБ
env-уровневая группа <uniq> 10 000 000 строк прошёл с кучей, зажатой до 512 МБ
Именно наблюдение вместо запрета и сделало эту таблицу неверной раньше. Без зажатой кучи прогон на 4 800 000 строк выходит на пик около 850 МБ — но укладывается в 256 МБ, когда больше не дают, потому что среда со сборщиком мусора берёт столько, сколько ей позволили. Пиковая резидентная память показывает, сколько среда ВЗЯЛА; потолок показывает, сколько прогону НУЖНО.
Границу ломают повторы, а не строки. Починка работает со строками, чьи наборы значений
столкнулись, и держит их; после max(20 000, count / 1000) таких строк она останавливается
и передаёт прогон in-memory движку, где память снова идёт за count. Те же 4 800 000 строк,
два пространства значений:
20 000 x 20 000 значений → не прошёл и с кучей, зажатой до 512 МБ
40 000 x 40 000 значений → уложился в 256 МБ
То есть рассуждать надо о том, сколько наборов значений могут дать ваши колонки против того,
сколько строк вы попросили, — а не о числе строк само по себе. Расширьте колонку, и тот же
прогон поместится. Сколько повторов даст конкретная конфигурация, формула «парадокса
дней рождения» предсказывает неплохо — прогон в 6 000 000 строк по 900 000 000 возможных пар
передал проверке 19 851 группу-кандидата там, где формула обещает около 20 000, — а вот во
что это обойдётся по времени, она не говорит. Запустите и смотрите --progress.
По-настоящему растут вместе с count две in-memory строки, и о них с 100 000 строк
предупреждает TDC299. Счётчик тоже растёт: 2 000 000 строк
потребовали 512 МБ, 4 000 000 — 1 ГБ, измерено тем же способом.
Ни одна форма uniq не работает на быстром потоковом движке. Он отказывает uniq
прямо по имени, поэтому конфиг, попросивший стриминг, получает отказ, а не данные с тихими
повторами:
tdcv2: stream mode: uniq (a whole-column rearrangement) ("K") is not supported yet — use mode="disk" instead (the router then picks an engine that can), or remove it.Обычно такого сообщения вы не видите. Движок выбирает маршрутизатор сам, и отказ всплывает только тогда, когда конфиг называет потоковый движок и тем самым просит сказать ему прямо.
Такой откат — не баг. Каждая из семи форм — это обещание про целый столбец, а движок,
который отвечал бы на него по одной строке, выдал бы данные, которые выглядят правильными
и таковыми не являются. Цена — память: на in-memory движке весь столбец держится целиком,
поэтому прогон с одной из этих форм упирается в ОЗУ, а не в диск.
preflight() оценивает это до запуска.
Список выше решается до прогона, и для --jobs это важно: параллельный прогон
выдаёт каждому воркеру форсированный потоковый движок, а воркеру откатываться некуда.
Страховка на крайний случай всё же есть — на редкий пограничный случай с
uniq/distinct/parent, которого список не видит: дисковый прогон, выбранный
автоматически, откатывается на in-memory движок, а не падает. Принудительный --engine 2
всё равно упадёт — ради этого его и форсируют:
tdcv2: a statistic ("Avg") is computed over every row of the run, including the ones after this one, so it cannot be computed one row at a time; the in-memory engine handles it (run without a forced streaming engine)Форсированный --engine 3 — исключение, и о нём стоит знать до любых замеров: он
отказывается только от uniq, который теснее, чем вытягивает его ограниченная починка, а
на всех остальных формах из списка выше откатывается на движок в памяти и печатает его
байты — код 0, ни слова. Так задумано: эти формы — ровно те, которых ленивый путь вообще не
выражает, и покрывать их и есть работа движка 3. Но значит и вот что: замер памяти с
--engine 3 на одной из них — это замер движка 1.
Что стримится, хотя кажется, что не должно
Две конструкции читают соседние колонки и всё равно стримятся — потому что каждой нужна только СВОЯ строка, а одну строку ленивый реестр как раз и умеет отдать:
| Конструкция | Стримится? | Почему |
|---|---|---|
<gen type="formula"> | да | Строка i вычисляется из строки i. Предыдущие не участвуют. |
| Параметр распределения, записанный выражением | да | Он меняет значение, в которое превращаются броски, а не их число. |
read="quantile" на файле | да | Файл сортируется один раз, дальше строка берёт на нём одну точку. |
<gen type="running"> | нет | Строка 900 000 000 И ЕСТЬ сумма всего, что было до неё. |
<gen type="stat"> | нет | Строки ПОСЛЕ этой — часть ответа. |
Граница проходит не по «вытянуто или вычислено», а по тому, нужна ли для ответа какая-то строка, кроме этой.
uniq на огромном выводе — ЭТО МЕДЛЕННО, а uniq + percent — самое медленное, что делает TDCГарантировать, что ни одна строка не повторится на огромном файле — принципиально дорого: точный движок генерирует, затем сортирует весь вывод и чинит каждую коллизию, и эта работа растёт быстрее, чем линейно, с числом строк. Уже сотни тысяч уникальных строк считаются минутами; миллионы могут идти часами и дольше. Память остаётся плоской — время нет.
Худший случай с огромным отрывом — uniq и percent на одних столбцах. Попасть в
точные доли и обеспечить отсутствие повторов одновременно — это задача раскладки с
ограничениями поверх сортировки, поэтому она ещё драматически медленнее: прогон, который
без одного из них был бы быстрым, с обоими может считаться неразумно долго. Если можете
отказаться от точности (пусть доли будут приблизительными) или от уникальности — откажитесь.
Для уникальности на масштабе берите то, что даётся дёшево по построению:
счётчик или диапазон number,
достаточно широкий, чтобы коллизия была исчезающе редка. uniq="true" — и особенно
uniq + percent — оставьте для объёмов, где можете позволить себе ожидание. Обычный
(без uniq) прогон любого размера остаётся быстрым.
Прежний mode="disk" теперь стоит по умолчанию — флаг не нужен. mode="memory"
гоняет маленький движок целиком в ОЗУ (точный, но не масштабируется) — тот
же, что стоит за объектным API
(toArray/iterate/getAt). Форсировать
конкретный движок можно через --engine 1|2|3;
--stream — легаси-алиас быстрого потокового движка.
Миллиард строк
<env count="1000000000" seed="s">
<sequence name="Gender"><gen type="text" value="M,F" percent="70,30"/></sequence>
<sequence name="Id"><gen type="increment" value="1"/></sequence>
</env>
Вот первые восемь строк этого прогона:
M,1 F,2 M,3 M,4 M,5 F,6 M,7 M,8
count, чтобы посмотреть прогон с percentПоменяете count="1000000000" на count="8", чтобы глянуть побыстрее, — и получите
другие строки: M M M M M M F F, а не те восемь, что выше. percent — это точная
квота, разложенная на весь count, поэтому уменьшение прогона перекладывает всю
колонку заново; маленький прогон не является началом большого. Шесть M и две F — это
ровно 70/30 от восьми, и в этом всё дело: квота соблюдается на любом размере, а значит
префиксом она быть не может. То же самое с uniq и со взвешенным паком — см.
Детерминизм и пропорции, раздел про раскладку на
весь набор.
Так что маленький count — правильный способ проверить форму (формат, пропорции,
что поля согласованы) и неправильный способ узнать, какое значение окажется в пятой
строке настоящего прогона.
Здесь TDC берёт быстрый потоковый движок (тяжёлого uniq нет). Он не материализует реестр значений: значение каждой строки считается «на лету» по её номеру, поэтому память O(числа полей), а не O(количества строк), и проценты остаются точными (ровно 70/30, никакого массива в памяти). Результат детерминирован.
Быстрый движок тянет почти всё: простые и составные
<sequence>, независимые генераторы
(text, number,
date, regex,
symbol, template),
точный percent,
счётчики,
встроенные (_count/_first/_last/_total),
parent-зависимости (любой глубины),
<distinct> и
<mix> — всё «на лету», точно и параллельно. Чего он не делает —
так это любой формы uniq. Уникальность — это обещание
про готовый столбец, а этот движок всегда видит только одну строку, поэтому любой uniq
уходит в другое место: на точный движок на диске или на in-memory, смотря как он написан.
Какой движок запустит ваш конфиг говорит, куда именно.
(В быстром движке parent работает, только если родитель — последовательность с конечным
списком значений (последовательность text). Наследование от
числового диапазона уводит конфиг на точный движок.)
Parent-зависимости в стриминге
Дочерняя последовательность с parent="Родитель.Значение"
активна ровно на тех строках, где родитель выдал это значение, и её проценты
точны внутри подмножества. Вложенность любой глубины (родитель → ребёнок →
внук).
<env count="1000" seed="s">
<sequence name="Пол"><gen type="text" value="М,Ж" percent="70,30"/></sequence>
<sequence name="Мужчина" parent="Пол.М"><gen type="text" value="Иван,Павел,Олег" percent="50,30,20"/></sequence>
<sequence name="Женщина" parent="Пол.Ж"><gen type="text" value="Анна,Ольга" percent="60,40"/></sequence>
</env>
На строках М заполнено поле Мужчина, на строках Ж — Женщина. Первые 6 строк
из 1000:
Ж,,Анна М,Иван, Ж,,Ольга Ж,,Анна М,Олег, М,Иван,
Распределение точно на каждом уровне — без массивов, каждая строка считается по своему номеру:
Пол М 700 Пол Ж 300 Мужчина Иван 350 Мужчина Павел 210 Мужчина Олег 140 Женщина Анна 180 Женщина Ольга 120
Ровно 700 М и 300 Ж; внутри 700 мужских — ровно 350/210/140 (50/30/20 от
700), внутри 300 женских — ровно 180/120 (60/40 от 300). На «чужих» строках
дочернее поле пустое: Мужчина пусто на женских строках, Женщина — на мужских.
Уникальность по всему датасету (uniq)
uniq="true" на составной последовательности делает кортеж всех её
полей уникальным по всему датасету. Это обещание про готовый столбец, поэтому считает
его точный движок на диске, а не потоковый. Каждый столбец раскладывается по своей точной
квоте, а потом кортежи сверяются друг с другом; если один столбец сам по себе уже даёт
каждой строке своё значение, проверка пропускается.
<env count="6" seed="s">
<sequence name="Combo" uniq="true">
<gen name="Letter" type="text" value="A,B,C"/>
<gen name="Digit" type="text" value="1,2"/>
</sequence>
</env>
Все 6 строк разные — это всё пространство 3 × 2 целиком:
C,2 A,1 B,2 A,2 C,1 B,1
То же работает и для env-уровневого <uniq>, где уникальный
кортеж строится из отдельных последовательностей, а не из полей одной:
<env count="6" seed="s">
<uniq>
<sequence name="A"><gen type="text" value="x,y,z"/></sequence>
<sequence name="B"><gen type="text" value="m,n"/></sequence>
</uniq>
</env>
z,n x,n x,m y,n y,m z,m
Память в обоих случаях остаётся ограниченной: столбцы считаются по номеру строки, а проверка на дубликаты идёт снаружи, а не держит датасет в памяти. Платить приходится временем, а не ОЗУ — см. предупреждение выше.
Ёмкость проверяется до старта. Если запрошено больше уникальных строк, чем могут дать данные, TDC честно падает сразу с понятной ошибкой — а не через восемь часов на середине файла:
tdcv2: uniq: group "K1 × K2" cannot produce 10000000 unique combinations — the values drawn for these sequences allow at most 100 distinct rows. Add more values to a member (more distinct names, wider ranges…) or lower the count.
Проверка идёт до того, как построена хоть одна строка, по одной только конфигурации:
список из десяти имён даёт десять значений, целочисленный диапазон 1..100 — сотню, а их
произведение и есть предел того, сколько разных строк группа вообще может удержать.
Поэтому count= в миллиардах получает ответ за миллисекунды, а не после того, как прогон
потянулся за памятью, которой не получит.
Единственное, чего она не делает, — не гадает. Если ёмкость какого-то члена по его записи не вычислить (регулярка, розыгрыш из пака, файл), группа здесь считается неограниченной, и ответ приходит от проверки по уже построенным колонкам, ровно как раньше. Отказ — всегда доказательство.
<mix> в стриминге
<mix> выбирает кейс для каждой строки по точному
проценту (та же математика, что и у percent), а затем собирает содержимое
кейса: текст, генераторы и вложенные <mix> любой глубины. Генератор или
вложенный <mix> внутри кейса работают на подмножестве строк этого кейса —
счётчик считает внутри кейса, а вложенные проценты точны внутри подмножества.
<env count="1000" seed="s">
<mix name="Status" percent="20,50,30">
<case><data>new</data></case>
<case><data>active-</data><gen type="number" value="1..3"/></case>
<case><data>closed</data></case>
</mix>
</env>
Первые 6 строк из 1000:
active-1 new active-3 closed active-1 closed
На 1000 строк разбивка точна: 200 new, 500 active-N, 300 closed. <mix>
сочетается и с parent — тогда он активен
только на строках родителя.
Почему percent + uniq — дорогая пара
Точные проценты раскладываются на весь столбец; уникальность проверяется по всему столбцу. По отдельности каждое посильно. Просьба сделать оба сразу — это задача раскладки с ограничениями поверх проверки, а это либо полная материализация, либо NP-трудный перебор. Точный движок на диске всё равно делает это на любом объёме: держит раскладку и чинит найденные коллизии — корректно и намного медленнее, чем любое из двух ограничений по отдельности.
Параллельность — автоматически
Генерация упирается в процессор, а не в диск (запись в сотни раз быстрее, чем счёт строк), а строки в потоковом движке независимы (каждая по своему номеру), поэтому TDC считает их на нескольких ядрах — «бесплатно» по архитектуре.
Указывать ничего не надо. Если конфиг разбивается (быстрый движок, без
встроенных генераторов) и файл достаточно большой, TDC берёт ядра − 1
(7 на 8-ядерной машине); иначе тихо считает на одном ядре:
npx tdcv2 customers.tdc -o customers.csv
Результат байт-в-байт одинаков независимо от числа ядер (при том же сиде): каждое ядро считает свой непрерывный диапазон строк во временный файл, а потом они склеиваются строго по порядку. Число потоков — это только про скорость, на данные оно не влияет.
Автоматическое число ещё и урезается под память — и это обычный ответ на вопрос «почему
не быстрее?». На каждого воркера считается 120 МБ плюс 50× размер на диске каждого
файла из src=, который он будет разбирать, и сумма не должна превышать половину
физической памяти. Поэтому конфиг, читающий большой CSV, получает заметно меньше
воркеров, чем есть ядер. О сокращении сообщается, только если --jobs задан явно;
автоматический прогон делает это молча.
Замер: 1 000 000 строк, шесть полей (счётчик, два шаблонных имени, колонка
percent, нормальное распределение, дата), файл 74 МБ, 12-ядерная машина:
--jobs | время | ускорение |
|---|---|---|
| 1 | 6.93 с | ×1 |
| 2 | 4.04 с | ×1.7 |
| 4 | 2.27 с | ×3.1 |
| 8 | 1.57 с | ×4.4 |
| 12 | 1.72 с | ×4.0 |
| авто | 1.69 с | ×4.1 |
Два вывода. Больше потоков не всегда быстрее: двенадцать потоков на двенадцати ядрах проигрывают восьми — они конкурируют за те же ядра и за диск. И числа выше принадлежат той машине, а не TDC.
Особенно это важно там, где ядра неодинаковы. Авто берёт ядра − 1, и на Apple M2 Max —
12 ядер, но 8 производительных и 4 экономичных — оно дотягивается до медленной четвёрки.
Тот же конфиг из шести полей, замер здесь:
--jobs | время |
|---|---|
| 1 | 8,82 с |
| 2 | 6,57 с |
| 4 | 5,00 с |
| 8 | 5,63 с |
| 12 | 6,40 с |
| авто | 6,07 с |
Авто мимо лучшего на 21%, а выигрыш в пике ×1,8, а не ×4,4. На колонке, тяжёлой по
вычислениям, бывает и хуже: formula с sin, log и gauss на 1 000 000 строк заняла
3,05 с в один поток и 4,71 с на авто — дольше по времени и в шесть раз больше процессора.
Значит так: на машине с одинаковыми ядрами не трогайте, а если прогон важен — засеките его
на нескольких значениях --jobs на той машине, которая будет его делать. Что не меняется
никогда — это данные: все эти прогоны дали одни и те же байты.
Ускорение зависит от того, насколько дорога строка. На совсем дешёвом конфиге
(два поля — счётчик и M,F) выигрыш всего около ×1.6: запуск потоков сам по себе
стоит времени, и на лёгкой работе этот оверхед съедает почти всю выгоду. Цифра
ускорения без конфига рядом бесполезна.
Задать число потоков вручную можно через --jobs N
(--jobs 1 принудительно один поток); вывод в любом случае одинаков:
npx tdcv2 customers.tdc --jobs 8 -o customers.csv
Иногда параллельность не включается, и правило тут уже, чем кажется. Разбивать прогон
по ядрам умеют оба построчных движка — и быстрый потоковый, и точный дисковый, — так что
конфиг, который всего лишь откатился со второго на третий, по-прежнему занимает все ядра.
Не разбивается прогон на движке в памяти, и одна форма в особенности: uniq="true" на
последовательности, которая переставляет генераторы внутри составной колонки, — работник,
считающий строку сам по себе, этого не воспроизведёт.
Группа <uniq> на уровне <env> — не эта форма, и она распараллеливается. Замер на
1 500 000 строк: --jobs 1 — 17,4 с процессорного времени, --jobs 8 — 52,0 с на восьми
работниках, а файлы совпадают байт в байт, ровно как --jobs и обещает.
Авто
про это молчит, но если вы попросили --jobs явно, TDC честно скажет почему. Вывод в
любом случае корректный.
И не включается ниже 100 000 строк. Меньше этого числа поднять потоки и склеить куски дороже, чем выигрыш от разбиения, — авто остаётся на одном. Число стоит знать по одной причине: это единственное, что меняется между прогоном на 99 999 строк и на 100 000. Если эти два прогона когда-нибудь разойдутся не только длиной — смотреть надо на разбиение.
Движок выбирается по конфигу, а не по железу
Какой из трёх движков запустить, TDC решает по содержимому конфига, а не по машине. Это важно: если бы выбор зависел от «сколько сейчас свободно памяти», то один и тот же конфиг с одним сидом выдавал бы разные данные на разных компьютерах — а воспроизводимость между машинами это центральная гарантия TDC. Поскольку маршрутизация зависит только от конфига, один конфиг всегда идёт по одному движку и даёт один результат везде.
Оценка по памяти (preflight(), ниже) — это только совет; она ничего не
переключает и не меняет вывод. Форсировать конкретный движок можно через
--engine 1|2|3 (продвинутое); mode="memory" —
маленький in-RAM движок для небольших данных.
Тот же переключатель есть внутри конфига — атрибут engine на <env>, то есть
флаг без командной строки:
<env count="1000" seed="s" engine="1">
1 — in-RAM, 2 — потоковый, 3 — точный на диске; любое другое значение это ошибка.
Предпочитайте mode="memory" / mode="disk": они говорят, чего вы хотите, а не какая
реализация это даст. Номера движков — аварийный выход, чтобы воспроизвести конкретное
поведение, и конфиг, прибитый к номеру, не получит выгоды от будущей маршрутизации.
Если заданы оба, engine перевешивает mode; --engine или --mode в командной
строке перевешивают любой из них.
Терминальные методы (библиотека)
Все методы отсюда идут через тот движок, который выбрал маршрутизатор, — объектные в том числе. Ни один из них не заставляет работать движок в памяти, а значит ни один не материализует реестр: расход O(числа полей), если только само возвращаемое значение не растёт.
| Метод | Текстовый вывод | Память | Когда использовать |
|---|---|---|---|
toString() | Собирается целиком | O(полей) + весь текст целиком | Маленькие / средние результаты |
toIterator() | По одной строке за раз | O(числа полей) | Большие текстовые результаты |
toStream() | Node Readable | O(числа полей) | Направить в файл / HTTP / архиватор |
writeFile() | Пишет фрагменты в файл | O(числа полей) | Самый простой большой файл |
| CLI | Пишет фрагменты | O(числа полей) | Командная строка |
toArray() | Объектные строки целиком | O(строк) — он их и возвращает | Маленькие / средние фикстуры |
iterate() | Объектные строки по одному | O(числа полей) | Объектный вывод, по строке |
getAt(index) | Одна объектная строка | O(числа полей) | Точечный доступ, не массовый |
toArray() — единственный объектный метод, чья память растёт вместе с count, и растёт
потому, что возвращаемый массив И ЕСТЬ все строки. iterate() и getAt() ничего подобного
не строят. Замер на конфиге в 50 000 000 строк: getAt(49_999_999) ответил за 4 мс, первые
три строки iterate() пришли за 2 мс, и куча в обоих случаях осталась ровной.
Для больших файлов берите CLI, writeFile(), toIterator(), toStream() — или
iterate(), если нужны объекты, а не текст:
const tdc = new TDC({ configFile: "./customers.tdc" });
tdc.writeFile("./customers.csv");
Или через stream:
import { createWriteStream } from "node:fs";
tdc.toStream().pipe(createWriteStream("./customers.csv"));
Проверка на деле: полмиллиона строк, память не растёт
Красивые слова про «O(числа полей)» стоят дороже на реальных цифрах. Возьмём конфиг на 500 000 строк и погоняем терминалы.
writeFile() — файл на диске. Пишет фрагменты по мере генерации:
bytes: 4388895 // ~4.4 МБ, 500 000 строк M,1 M,2 M,3
toIterator() — проходим все строки, память стоит на месте. Замер RSS
процесса на контрольных точках по мере роста числа строк:
rows=100000 RSS=128 MB rows=200000 RSS=128 MB rows=300000 RSS=128 MB rows=400000 RSS=128 MB rows=500000 RSS=128 MB
Линия плоская: 128 МБ на 100 000 строк и те же 128 МБ на 500 000. Смотрите на ровность линии, а не на абсолютное число (RSS зависит от машины и версии Node — здесь Apple M2 Max, Node 20); плоской линия обязана быть везде.
toStream() равен writeFile() байт-в-байт. Оба идут по одному потоковому
пути:
new TDC({ configFile: "./customers.tdc" })
.toStream()
.pipe(createWriteStream("out2.csv"));
// md5(out2.csv) === md5(out.csv) → true
preflight() — оценка риска по памяти
preflight() оценивает риск по памяти до генерации, сравнивая прикидку с
общим объёмом ОЗУ машины (а не с «сколько свободно прямо сейчас» —
операционная система отдаёт память процессу по мере надобности, так что
мгновенно-свободное число обманчиво).
const diagnostic = tdc.preflight();
На обычном (дисковом) запуске даже полмиллиона строк — не риск, preflight()
возвращает undefined. Он предупреждает только при явном mode="memory" на
большом count, где данные действительно материализуются:
// диск (по умолчанию), 500 000 строк:
new TDC({ configFile: "./customers.tdc" }).preflight();
// → undefined
// mode="memory", 50 000 000 строк — возвращается Diagnostic:
const d = new TDC({
configFile: "./customers.tdc",
mode: "memory",
count: 50_000_000,
}).preflight();
d.severity warning
d.code TDC200
d.message estimated memory need (~20.5 GB) is a large share of this
machine's RAM (32.0 GB) — may lean on swap and slow down
d.hint This will still run; for very large datasets mode="disk"
keeps memory flat regardless of count.Так что на обычном дисковом запуске preflight почти никогда не срабатывает: потоковый движок держит O(числа полей), а не O(количества строк), поэтому миллиард строк проходит спокойно — он для этого и создан. Оценка — только совет; она не переключает движок и не меняет вывод.
Если вы точно будете потреблять потоковый вывод через toString(), назовите
сценарий явно:
const diagnostic = tdc.preflight({ output: "streaming" });
Что материализуется в ОЗУ
Оба дисковых движка ничего лишнего в памяти не держат. Материализация происходит у
маленького in-RAM движка — до него доводят объектное API
(toArray/iterate/getAt), явный mode="memory" и любая из семи форм конфига,
которые возвращают дисковый прогон обратно в память.
В этом случае заранее держатся:
- встроенные
_count,_first,_last,_total; - каждая простая
<sequence>; - каждое поле составной последовательности;
- parent-filtered массивы значений или
undefined; - планы связывания строк CSV для связанных внешних данных.
Грубая оценка: count × число слотов последовательностей. Например, такая составная
последовательность:
<sequence name="Person">
<gen name="FirstName" type="template" value="person.female.firstName"/>
<gen name="LastName" type="template" value="person.female.lastName"/>
</sequence>
Елена Кузьмина Елена Андреева Ульяна Николаева Евгения Ткаченко Юлия Иванова
занимает два слота последовательности: Person.FirstName и Person.LastName.
Практические правила
- Для файла любого размера просто используйте
writeFile()или CLI — диск по умолчанию, память не растёт с числом строк. - Перед очень большим прогоном сверьте конфиг с восемью формами, которые возвращают его
в память. Главная из них — простой
uniq="true". - Чтобы ускорить много строк, добавьте
--jobs N(на быстром движке). toString()удобен для тестов и маленьких результатов, но собирает весь текст в одну строку — не для больших файлов.toArray()возвращает все строки объектами, поэтому его память — это сам массив; вот он для небольших наборов.iterate()иgetAt()— нет: они идут на том же движке, что и текстовый вывод, и ничего не держат, так чтоiterate()вполне годится, чтобы выдавать объекты потоком.
Смотрите также
- CLI —
--jobs,--mode,--engine. - Уникальные значения —
uniq,<uniq>,<distinct>подробно. - Иерархические зависимости —
parentподробно. - Языковые привязки — библиотечное API целиком.