Salidas grandes y streaming
Por omisión, TDC genera directo a disco y funciona a cualquier tamaño — hasta miles de millones de filas. La memoria no crece con la cantidad de filas: cada fila se calcula al vuelo a partir de su número, no se guarda en un arreglo. Sin preparación especial — así es simplemente como funciona una corrida normal. Un puñado de formas de configuración son la excepción y están listadas en Qué motor corre su configuración.
Las salidas de ejemplo de esta página son ilustrativas y pueden variar entre versiones del core; donde la página hace una afirmación numérica («exactamente 70/30», «128 MB planos»), observe la forma del resultado, no los bytes exactos.
- Aguardar cada fila en memoria: el costo crece con la corrida
- Bstreaming: una ventana a la vez, así que el costo se mantiene plano por larga que sea la corrida
Dos motores de disco, elegidos por usted
Bajo «disco» corren dos motores, y TDC elige entre ellos a partir de su configuración:
-
El motor rápido de streaming — se usa para casi todo. Es perezoso, multihilo (vea
--jobs), con memoria O(cantidad de campos). Porcentajes exactos, dependenciasparent,<mix>,<distinct>— todo al vuelo. -
El motor exacto en disco — para una promesa sobre la columna terminada y no sobre la fila actual: un grupo
<uniq>a nivel de env,uniq="true"en una secuencia compuesta o en un contador, y unparentcuyo padre no es una secuencia de texto. Garantiza el resultado de forma exacta y su memoria se mantiene acotada — pero lo paga revisando los datos con un ordenamiento externo y una pasada de reparación, y esa revisión se vuelve drásticamente más lenta a medida que crece el número de filas (vea la advertencia abajo).La unicidad es una promesa sobre el conjunto terminado, no sobre una fila, y no se puede resolver fila a fila. Por eso mismo
--jobsse niega a repartir una configuración que la pide: un worker sólo ve su propio rango de filas, y no distinguiría un duplicado fuera de ese rango de un valor que nunca ha visto.
El modo disco tiene un tercer destino, y es el que conviene conocer: seis formas de
configuración mandan la corrida de vuelta al motor pequeño en memoria, donde la memoria
crece con count. Una de ellas es la manera más común de escribir uniq. Qué motor corre
su configuración lista las cinco.
La elección es determinista — se basa en la configuración, no en el hardware — así que la misma configuración da el mismo resultado en cualquier máquina (la reproducibilidad entre máquinas es una garantía central de TDC).
Qué motor corre su configuración
mode="disk" pide memoria acotada. No siempre la consigue. TDC lee primero la
configuración, y seis formas rutean la corrida al motor pequeño en memoria, cuya
memoria crece con la cantidad de filas. Se revisan en este orden.
| Forma | Por qué no puede correr en streaming |
|---|---|
Un value de template que interpola un campo — common.vehicle.model.${{Brand}} | La dirección no se conoce hasta que la columna vecina tiene un valor, así que hay que resolverla fila por fila contra las otras secuencias. |
weight= y row= en el mismo generador file | Ponderar un sorteo de fila enlazada hasta una cuota exacta necesita los totales del archivo por adelantado. |
Un generador de paquete que declara sus propias proporciones — percent= en el archivo del paquete | La proporción es una cuota sobre la columna entera. Calculada fila a fila se vuelve una cuota sobre una sola fila, y cada fila se va a la proporción más grande. |
uniq="true" sobre una sola columna sorteada, sola o junto a texto literal | El sorteo corre sin reemplazo, así que tanto la bolsa como el conjunto de lo ya tomado abarcan la columna entera. |
type="http" — una llamada de red | No es reproducible ni síncrona, y se resuelve en una pasada asíncrona después de que se arma el resto del registro. |
Un percent= dentro de una rama de <switch> con varias claves — is="US|CA|MX" — o dentro de <default> | La proporción es una cuota sobre las filas propias de la rama, y esas filas son una unión de subconjuntos, o lo que dejaron las demás ramas. Ninguna de las dos se puede numerar fila a fila. |
Todo lo demás que pregunte por la columna terminada va al motor exacto en disco, y el resto corre en streaming.
Así que uniq cae en dos motores distintos según cómo esté escrito:
uniq escrito como | Motor | Memoria |
|---|---|---|
uniq="true" sobre una sola columna sorteada — text, number, date, template | en memoria | crece con count |
uniq="true" sobre una columna hecha de una parte sorteada y literales <data> | en memoria | crece con count |
uniq="true" sobre un contador | exacto en disco | acotada |
uniq="true" sobre una secuencia compuesta (campos <gen> con nombre) | exacto en disco | acotada |
Un grupo <uniq> a nivel de env | exacto en disco | acotada |
Ninguna forma de uniq corre en el motor rápido de streaming. Rechaza uniq por su
nombre, así que a una configuración que pidió streaming se le dice, en vez de entregarle
datos que se repiten en silencio:
tdcv2: stream mode: uniq (a whole-column rearrangement) ("K") is not supported yet — run without mode="stream" (the in-memory engine handles it), or remove it.Normalmente usted nunca ve un mensaje así. El ruteo elige el motor por su cuenta, y un rechazo solo sale a la luz cuando una configuración nombra un motor de streaming y con eso pidió que se le dijera.
Una bajada de motor no es un error. Cada una de las seis formas es una promesa sobre una
columna entera, y un motor que la respondiera desde una sola fila emitiría datos que se
ven bien y no lo son. Lo que cuesta es memoria: en el motor en memoria se retiene la
columna entera, así que una corrida con una de estas formas queda acotada por la RAM y no
por el disco. preflight() lo estima
antes de la corrida.
Existe una ruta más, para lo que la lista no puede ver de antemano. Si el motor de
streaming termina rechazando una configuración de todos modos — un total
acumulado, un parent="Nombre" pelado sin
valor, una referencia a un pool — una corrida a disco ruteada
automáticamente cae de vuelta al motor en memoria en lugar de fallar. Un --engine 2
forzado igual falla, que es justamente el punto de forzarlo.
uniq sobre una salida enorme es LENTO — y uniq + percent es lo más lento que hace TDCGarantizar que ninguna fila se repita en un archivo enorme es fundamentalmente caro: el motor exacto genera, luego ordena toda la salida y repara cada colisión, y ese trabajo crece más rápido que linealmente con el número de filas. Cientos de miles de filas únicas ya tardan minutos; los millones pueden correr horas o más. La memoria se mantiene plana — el tiempo no.
El peor caso, por lejos, es uniq y percent sobre las mismas columnas. Acertar
proporciones exactas y no repetir a la vez es un problema de acomodo con restricciones
encima del ordenamiento, así que es dramáticamente más lento otra vez: una corrida que
sería rápida sin uno de los dos puede tardar un tiempo irrazonable con ambos. Si puede
soltar la exactitud (deje que las proporciones sean aproximadas) o la unicidad, hágalo.
Para unicidad a escala, prefiera lo que es barato por construcción: un
contador, o un rango number lo
bastante amplio como para que una colisión sea prácticamente imposible. Reserve
uniq="true" — y sobre todo uniq + percent — para los tamaños donde pueda permitirse
la espera. Una corrida normal (sin uniq) de cualquier tamaño sigue siendo rápida.
El viejo mode="disk" ahora es el valor por omisión — no hace falta ninguna bandera.
mode="memory" corre un motor pequeño en RAM (exacto, pero que no escala) — el mismo
que está detrás de la API de objetos
(toArray/iterate/getAt). Fuerce un motor específico con
--engine 1|2|3; --stream es un alias heredado del motor rápido
de streaming.
Mil millones de filas
<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>
Estas son las primeras ocho filas de esa corrida:
M,1 F,2 M,3 M,4 F,5 M,6 M,7 M,8
count para previsualizar una corrida que usa percentCambie count="1000000000" por count="8" para verlo rápido y obtendrá filas
distintas: M M M M M M F F, no las ocho de arriba. percent es una cuota exacta
repartida sobre el count completo, así que reducir la corrida vuelve a repartir la
columna entera; la corrida pequeña no es el comienzo de la grande. Seis M y dos F son
exactamente 70/30 de ocho, y ese es el punto: la cuota se respeta en cualquier tamaño, y
por eso mismo no puede ser además un prefijo. Lo mismo con uniq y con un pack
ponderado — vea Determinismo y proporciones, la
sección sobre los diseños que abarcan toda la ejecución. Un count pequeño es la forma
correcta de comprobar la forma (el formato, las proporciones, que los campos
concuerden) y la forma incorrecta de predecir qué valor caerá en la fila 5 de la corrida
real.
Aquí TDC usa el motor rápido de streaming (no hay uniq pesado). No materializa un registro — el valor de cada fila se calcula a partir de su número, así que la memoria es O(campos), no O(filas), y los porcentajes se mantienen exactos (precisamente 70/30, sin ningún arreglo guardado). El resultado es determinista.
El motor rápido resuelve casi todo: <sequence>
simples y compuestas, generadores independientes
(text, number,
date, regex,
symbol, template),
percent exacto, contadores,
los integrados (_count/_first/_last/_total),
dependencias parent (a cualquier profundidad),
<distinct> y <mix> — todo al vuelo,
exacto y en paralelo. Lo que no hace es ninguna forma de
uniq. La unicidad es una promesa sobre la columna
terminada y este motor solo ve una fila a la vez, así que todo uniq se va a otra parte —
al motor exacto en disco o al motor en memoria, según cómo esté escrito. Qué motor corre
su configuración dice cuál.
(En el motor rápido, parent funciona solo cuando el padre es una secuencia con una lista
finita de valores — una secuencia text. Heredar de un rango
numérico manda la configuración al motor exacto.)
Dependencias parent en el stream
Una secuencia hija con parent="Parent.Value" está activa
exactamente en las filas donde el padre produjo ese valor, y sus porcentajes son exactos
dentro del subconjunto. El anidamiento llega a cualquier profundidad (padre → hijo →
nieto).
<env count="1000" seed="s">
<sequence name="Gender"><gen type="text" value="M,F" percent="70,30"/></sequence>
<sequence name="Male" parent="Gender.M"><gen type="text" value="Juan,Carlos,Miguel" percent="50,30,20"/></sequence>
<sequence name="Female" parent="Gender.F"><gen type="text" value="María,Elena" percent="60,40"/></sequence>
</env>
En las filas M se llena el campo Male; en las filas F, el campo Female. Las
primeras 6 de 1000 filas:
F,,María M,Carlos, F,,Elena F,,María F,,María F,,María
La distribución es exacta en todos los niveles — sin arreglos, cada fila calculada a partir de su número:
Gender M 700 Gender F 300 Male Juan 350 Male Carlos 210 Male Miguel 140 Female María 180 Female Elena 120
Exactamente 700 M y 300 F; dentro de los 700 hombres, exactamente 350/210/140 (50/30/20
de 700), y dentro de las 300 mujeres, exactamente 180/120 (60/40 de 300). En las filas
«ajenas» el campo hijo queda en blanco — Male está vacío en las filas femeninas, y
Female en las masculinas.
Unicidad en todo el conjunto de datos (uniq)
uniq="true" en una secuencia compuesta hace que la tupla de todos
sus campos sea única en todo el conjunto de datos. Eso es una promesa sobre la columna
terminada, así que corre en el motor exacto en disco y no en el de streaming. Cada columna
se reparte hasta su cuota exacta y luego las tuplas se comparan entre sí; cuando una sola
columna ya le da a cada fila un valor distinto, la comparación se omite.
<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>
Las 6 filas son distintas — ese es el espacio completo de 3 × 2:
C,2 A,1 B,2 A,2 C,1 B,1
Lo mismo vale para el <uniq> a nivel de env, donde la tupla única se
construye a partir de secuencias separadas en vez de campos de una sola:
<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
La memoria se mantiene acotada en las dos formas: las columnas se resuelven a partir del
número de fila, y la revisión de duplicados corre por fuera en vez de retener el conjunto
de datos. Lo que cuesta es tiempo, no RAM — vea la advertencia arriba. Límites: los
campos de un uniq compuesto tienen que ser listas text.
La capacidad se revisa antes de que empiece la corrida. Si pide más filas únicas de las que los datos pueden producir, TDC falla de inmediato con un error claro — no ocho horas después, a media escritura del archivo:
tdcv2: uniq "K" is infeasible — its data supports at most 100 distinct rows, but 5000000000 were requested. Widen a column's values or lower count.
<mix> en el stream
<mix> elige un case para cada fila por porcentaje exacto (la
misma matemática que percent) y luego arma el cuerpo del case: texto, generadores y
<mix> anidados a cualquier profundidad. Un generador o un <mix> anidado dentro de un
case corre sobre el subconjunto de filas de ese case — su contador cuenta dentro del
case, y los porcentajes anidados son exactos dentro del subconjunto.
<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>
Las primeras 6 de 1000 filas:
active-1 new active-3 closed active-1 closed
Sobre 1000 filas el reparto es exacto: 200 new, 500 active-N, 300 closed. <mix>
también se compone con parent — y entonces está activo
solo en las filas del padre.
Por qué percent + uniq es la pareja cara
Los porcentajes exactos se reparten sobre la columna entera; la unicidad se revisa sobre la columna entera. Cada cosa por separado es asequible. Pedir las dos a la vez es un problema de acomodo con restricciones encima de la revisión, y eso es o materialización total o una búsqueda NP-difícil. El motor exacto en disco lo hace igual, a cualquier tamaño, reteniendo el acomodo y reparando las colisiones que encuentra — de forma correcta, y mucho más lentamente que cualquiera de las dos restricciones por separado.
Paralelismo — automático
La generación está limitada por CPU, no por disco (escribir es mucho más rápido que calcular filas), y las filas del motor de streaming son independientes (cada una sale de su propio número), así que TDC las calcula en varios núcleos — gratis, por arquitectura.
Usted no configura nada. Si la configuración es divisible (motor rápido, sin
generadores integrados en línea) y el archivo es lo bastante grande, TDC usa
núcleos − 1 (7 en una máquina de 8 núcleos); si no, corre calladamente en un solo núcleo:
npx tdcv2 customers.tdc -o customers.csv
El resultado es idéntico byte por byte sin importar la cantidad de núcleos (con la misma semilla): cada núcleo calcula un rango contiguo de filas hacia un archivo temporal, y luego se concatenan estrictamente en orden. La cantidad de hilos es solo cuestión de velocidad — nunca afecta los datos.
Una medición — 1 000 000 de filas, seis campos (un contador, dos nombres de plantilla, una
columna con percent, una distribución normal y una fecha), un archivo de 74 MB, en una
máquina de 12 núcleos:
--jobs | tiempo | aceleración |
|---|---|---|
| 1 | 6.93 s | ×1 |
| 2 | 4.04 s | ×1.7 |
| 4 | 2.27 s | ×3.1 |
| 8 | 1.57 s | ×4.4 |
| 12 | 1.72 s | ×4.0 |
| auto | 1.69 s | ×4.1 |
Dos lecciones. Más hilos no siempre es más rápido: doce hilos en doce núcleos pierden
contra ocho — se pelean por los mismos núcleos y el mismo disco. Y por lo general no hay
nada que ajustar: auto toma núcleos − 1 y se queda a ~8 % del mejor resultado, lo cual
no vale el alboroto.
La aceleración depende de qué tan cara sea una fila. En una configuración de verdad barata
(dos campos, un contador y M,F) la ganancia es de apenas ~×1.6 — levantar hilos cuesta
tiempo, y en trabajo liviano ese costo se come casi toda la ganancia. Una cifra de
aceleración sin su configuración al lado no significa nada.
Ajústelo a mano con --jobs N si quiere (--jobs 1 fuerza un solo
hilo); la salida es idéntica de cualquier forma:
npx tdcv2 customers.tdc --jobs 8 -o customers.csv
A veces el paralelismo no entra en acción. Solo el motor rápido de streaming reparte
una corrida entre núcleos, así que todo lo que se rutea fuera de
él — cualquier uniq, un generador http, un enlace
de fila ponderado — corre en un solo hilo. Auto se queda callado al respecto, pero si pidió
--jobs explícitamente TDC le dice por qué. La salida es correcta de cualquier forma.
El motor se elige por su configuración, no por su hardware
Cuál de los tres motores corre lo decide TDC por el contenido de la configuración, nunca por la máquina. Esto importa: si la elección dependiera de «cuánta RAM hay libre en este momento», entonces la misma configuración con la misma semilla podría producir datos distintos en computadoras distintas — y la reproducibilidad entre máquinas es la garantía central de TDC. Como el ruteo depende solo de la configuración, una configuración siempre toma un motor y da un resultado en todas partes.
La estimación de memoria (preflight(), abajo) es solo un consejo; no cambia nada ni
altera la salida. Para forzar un motor específico use
--engine 1|2|3 (avanzado); mode="memory" es el motor pequeño en
RAM para conjuntos de datos chicos.
El mismo forzado existe dentro del config, como engine en <env>: la bandera sin
línea de comandos:
<env count="1000" seed="s" engine="1">
1 es en memoria, 2 en flujo, 3 exacto en disco; cualquier otro valor es un error.
Prefiera mode="memory" / mode="disk", que dicen qué quiere en lugar de qué
implementación se lo da: los números de motor son una salida de emergencia para
reproducir un comportamiento concreto, y un config clavado a un motor no se beneficiará
de un mejor enrutado más adelante. Si están los dos, engine gana sobre mode; un
--engine o --mode en la línea de comandos gana sobre cualquiera de ellos.
Métodos terminales (la biblioteca)
La salida de texto (toString/toIterator/toStream/writeFile/CLI) pasa por disco y
no materializa un registro — O(campos). Los métodos de objetos
(toArray/iterate/getAt) devuelven objetos de JS a través del motor pequeño en RAM,
así que retienen datos en memoria (algo razonable para los conjuntos chicos para los que
existe la API de objetos).
| Método | Salida de texto | Memoria | Sirve para |
|---|---|---|---|
toString() | recolectada completa | O(campos) + texto completo | resultados chicos / medianos |
toIterator() | una fila a la vez | O(campos) | resultados de texto grandes, fila por fila |
toStream() | Readable de Node | O(campos) | canalizar a un archivo / HTTP / un archivador |
writeFile() | por trozos a un archivo | O(campos) | la salida más simple para un archivo grande |
| CLI | por trozos | O(campos) | la línea de comandos |
toArray() | filas como objetos, completas | materializadas en RAM | fixtures de objetos chicas / medianas |
iterate() | filas como objetos, una por una | materializadas en RAM | salida de objetos, una fila a la vez |
getAt(index) | una fila como objeto | materializada por llamada | acceso puntual, no masivo |
Para archivos grandes, use la CLI, writeFile(), toIterator() o toStream():
const tdc = new TDC({ configFile: "./customers.tdc" });
tdc.writeFile("./customers.csv");
O mediante un stream:
import { createWriteStream } from "node:fs";
tdc.toStream().pipe(createWriteStream("./customers.csv"));
Prueba: medio millón de filas, la memoria se mantiene plana
Las palabras bonitas sobre «O(campos)» valen más con números reales. Tome una configuración de 500 000 filas y ejecute los terminales.
writeFile() — un archivo en disco. Escribe por trozos conforme genera:
bytes: 4388895 // ~4.4 MB, 500,000 rows M,1 M,2 M,3
toIterator() — recorrer todas las filas, y la memoria no se mueve. Muestreando el RSS
del proceso en puntos de control conforme crece la cantidad de filas:
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
La línea es plana — 128 MB con 100 000 filas y los mismos 128 MB con 500 000. Fíjese en lo plano, no en el número absoluto (el RSS depende de la máquina y la versión de Node — esto fue en una Apple M2 Max con Node 20); la línea es plana en todos lados.
toStream() es igual a writeFile() byte por byte. Ambos toman el mismo camino de
streaming:
new TDC({ configFile: "./customers.tdc" })
.toStream()
.pipe(createWriteStream("out2.csv"));
// md5(out2.csv) === md5(out.csv) → true
preflight() — una estimación del riesgo de memoria
preflight() estima el riesgo de memoria antes de generar, comparando la estimación con la
RAM total de la máquina (no con la «libre en este momento» — el sistema operativo le
entrega memoria a un proceso bajo demanda, así que una cifra de libre instantáneo es
engañosa).
const diagnostic = tdc.preflight();
En una corrida normal (a disco) ni siquiera medio millón de filas representa riesgo —
preflight() devuelve undefined. Solo advierte cuando hay un mode="memory" explícito
con un count grande, donde los datos sí se materializan de verdad:
// disk (default), 500,000 rows:
new TDC({ configFile: "./customers.tdc" }).preflight();
// → undefined
// mode="memory", 50,000,000 rows — returns a 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 (~20981 MB) is a large share of this
machine's RAM (32768 MB) — 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.Así que en una corrida ordinaria a disco preflight prácticamente nunca se dispara: el motor de streaming retiene O(campos), no O(filas), así que mil millones de filas pasan tan tranquilas — para eso está hecho. La estimación es solo un consejo; no cambia de motor ni altera la salida.
Si sabe que va a consumir la salida del streaming con toString(), nombre el escenario
explícitamente:
const diagnostic = tdc.preflight({ output: "streaming" });
Qué se materializa en RAM
Los dos motores de disco no guardan nada extra en memoria. La materialización ocurre en el
motor pequeño en RAM — al que se llega por la API de objetos
(toArray/iterate/getAt), por un mode="memory" explícito y por cualquiera de las
seis formas de configuración que rutean una corrida a disco de vuelta a
él. Ahí retiene, de entrada:
- los integrados
_count,_first,_last,_total; - cada
<sequence>simple; - cada campo de una secuencia compuesta;
- los arreglos de valores filtrados por parent, o
undefined; - los planes de enlace de filas CSV para datos externos enlazados.
Una estimación gruesa: count × cantidad_de_ranuras_de_secuencia. Por ejemplo, esta
secuencia compuesta:
<sequence name="Person">
<gen name="FirstName" type="template" value="person.female.firstName"/>
<gen name="LastName" type="template" value="person.lastName"/>
</sequence>
Salomé Zambrano Salomé Juárez Sol Miranda Olivia Rodas Úrsula López
ocupa dos ranuras de secuencia: Person.FirstName y Person.LastName.
Reglas prácticas
- Para un archivo de cualquier tamaño, simplemente use
writeFile()o la CLI — a disco por omisión, y la memoria no crece con las filas. - Antes de una corrida muy grande, revise la configuración contra las seis formas que la
rutean de vuelta a memoria. El
uniq="true"simple es la que hay que vigilar. - Para acelerar muchas filas, agregue
--jobs N(en el motor rápido). toString()es cómodo para pruebas y resultados chicos, pero junta todo el texto en una sola cadena — no sirve para archivos grandes.toArray()/iterate()/getAt()materializan filas como objetos en RAM, así que no reemplazan la salida de archivos por streaming — son para conjuntos chicos.
Vea también
- CLI —
--jobs,--mode,--engine. - Valores únicos —
uniq,<uniq>y<distinct>a fondo. - Dependencias jerárquicas —
parenta fondo. - Bindings de lenguajes — la API de la biblioteca completa.