Saltar al contenido principal

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.

La memoria frente a las filas producidas. Es un esquema, no una medición — lo que importa es la forma de cada curva, no su altura.
  • 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, dependencias parent, <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 un parent cuyo 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 --jobs se 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.

FormaPor 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 filePonderar un sorteo de fila enlazada hasta una cuota exacta necesita los totales del archivo por adelantado.
Un generador de paquete que declara sus propias proporcionespercent= en el archivo del paqueteLa 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 literalEl 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 redNo 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 comoMotorMemoria
uniq="true" sobre una sola columna sorteada — text, number, date, templateen memoriacrece con count
uniq="true" sobre una columna hecha de una parte sorteada y literales <data>en memoriacrece con count
uniq="true" sobre un contadorexacto en discoacotada
uniq="true" sobre una secuencia compuesta (campos <gen> con nombre)exacto en discoacotada
Un grupo <uniq> a nivel de envexacto en discoacotada

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:

./run uniq.tdc --engine 2
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 TDC

Garantizar 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.

Escotillas de escape avanzadas

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:

./run big.tdc (primeras 8 filas de los mil millones)
M,1
F,2
M,3
M,4
F,5
M,6
M,7
M,8
No reduzca count para previsualizar una corrida que usa percent

Cambie 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:

./run parent.tdc (primeras 6 de 1000)
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:

./run parent.tdc (conteos sobre 1000 filas)
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:

./run uniq.tdc (las 6 filas)
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>
./run env-uniq.tdc (las 6 combinaciones de 3 x 2)
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:

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

./run mix.tdc (primeras 6 de 1000)
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:

--jobstiempoaceleración
16.93 s×1
24.04 s×1.7
42.27 s×3.1
81.57 s×4.4
121.72 s×4.0
auto1.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étodoSalida de textoMemoriaSirve para
toString()recolectada completaO(campos) + texto completoresultados chicos / medianos
toIterator()una fila a la vezO(campos)resultados de texto grandes, fila por fila
toStream()Readable de NodeO(campos)canalizar a un archivo / HTTP / un archivador
writeFile()por trozos a un archivoO(campos)la salida más simple para un archivo grande
CLIpor trozosO(campos)la línea de comandos
toArray()filas como objetos, completasmaterializadas en RAMfixtures de objetos chicas / medianas
iterate()filas como objetos, una por unamaterializadas en RAMsalida de objetos, una fila a la vez
getAt(index)una fila como objetomaterializada por llamadaacceso 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:

node writeFile.js (500 000 filas)
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:

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

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();
node preflight.js
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>
./run person.tdc (${{Person.FirstName}} ${{Person.LastName}})
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