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

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

По умолчанию 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. Но значит и вот что: замер памяти с --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>

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

./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() вполне годится, чтобы выдавать объекты потоком.

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