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

Большие объёмы и стриминг

По умолчанию 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, templatein-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 прямо по имени, поэтому конфиг, попросивший стриминг, получает отказ, а не данные с тихими повторами:

./run uniq.tdc --engine 2
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 всё равно упадёт — ради этого его и форсируют:

./run report.tdc --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. Прогон сообщает об этом в stderr, потому что замер памяти с --engine 3 на одной из них — это замер движка 1:

./run report.tdc --engine 3
tdcv2: note: engine 3 cannot build this config, so engine 1 (in memory) runs it: "R" adds up the rows before it

tdcv2 check показывает тот же выбор ещё до запуска.

Что стримится, хотя кажется, что не должно​

Две конструкции читают соседние колонки и всё равно стримятся — потому что каждой нужна только СВОЯ строка, а одну строку ленивый реестр как раз и умеет отдать:

КонструкцияСтримится?Почему
<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>

Вот первые восемь строк этого прогона:

./run big.tdc (первые 8 строк из миллиарда)
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:

./run parent.tdc (первые 6 из 1000)
Ж,,Анна
М,Иван,
Ж,,Ольга
Ж,,Анна
М,Олег,
М,Иван,

Распределение точно на каждом уровне — без массивов, каждая строка считается по своему номеру:

./run parent.tdc (подсчёт по 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 целиком:

./run uniq.tdc (все 6 строк)
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>
./run env-uniq.tdc (все 6 комбинаций 3 x 2)
z,n
x,n
x,m
y,n
y,m
z,m

Память в обоих случаях остаётся ограниченной: столбцы считаются по номеру строки, а проверка на дубликаты идёт снаружи, а не держит датасет в памяти. Платить приходится временем, а не ОЗУ — см. предупреждение выше.

Ёмкость проверяется до старта. Если запрошено больше уникальных строк, чем могут дать данные, TDC честно падает сразу с понятной ошибкой — а не через восемь часов на середине файла:

./run oversized-uniq.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:

./run mix.tdc (первые 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времяускорение
16.93 с×1
24.04 с×1.7
42.27 с×3.1
81.57 с×4.4
121.72 с×4.0
авто1.69 с×4.1

Два вывода. Больше потоков не всегда быстрее: двенадцать потоков на двенадцати ядрах проигрывают восьми — они конкурируют за те же ядра и за диск. И числа выше принадлежат той машине, а не TDC.

Особенно это важно там, где ядра неодинаковы. Авто берёт ядра − 1, и на Apple M2 Max — 12 ядер, но 8 производительных и 4 экономичных — оно дотягивается до медленной четвёрки. Тот же конфиг из шести полей, замер здесь:

--jobsвремя
18,82 с
26,57 с
45,00 с
85,63 с
126,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 ReadableO(числа полей)Направить в файл / 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() — файл на диске. Пишет фрагменты по мере генерации:

node writeFile.js (500 000 строк)
bytes: 4388895        // ~4.4 МБ, 500 000 строк
M,1
M,2
M,3

toIterator() — проходим все строки, память стоит на месте. Замер RSS процесса на контрольных точках по мере роста числа строк:

node measure.js
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();
node preflight.js
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>
./run person.tdc (${{Person.FirstName}} ${{Person.LastName}})
Елена Кузьмина
Елена Андреева
Ульяна Николаева
Евгения Ткаченко
Юлия Иванова

занимает два слота последовательности: Person.FirstName и Person.LastName.

Практические правила​

  • Для файла любого размера просто используйте writeFile() или CLI — диск по умолчанию, память не растёт с числом строк.
  • Перед очень большим прогоном сверьте конфиг с восемью формами, которые возвращают его в память. Главная из них — простой uniq="true".
  • Чтобы ускорить много строк, добавьте --jobs N (на быстром движке).
  • toString() удобен для тестов и маленьких результатов, но собирает весь текст в одну строку — не для больших файлов.
  • toArray() возвращает все строки объектами, поэтому его память — это сам массив; вот он для небольших наборов. iterate() и getAt() — нет: они идут на том же движке, что и текстовый вывод, и ничего не держат, так что iterate() вполне годится, чтобы выдавать объекты потоком.

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