Skip to content

feat(data_pipeline): versioned shard dataset (WebDataset-style tars + parquet manifest) with DDP loader - #390

Draft
Dionysus326 wants to merge 63 commits into
tier4-mainfrom
feat/webdataset-data-pipeline
Draft

Dionysus326 wants to merge 63 commits into
tier4-mainfrom
feat/webdataset-data-pipeline

Conversation

@Dionysus326

@Dionysus326 Dionysus326 commented Sep 3, 2026 •

Copy link
Copy Markdown

Summary

Adds an opt-in versioned shard dataset pipeline for training data, as an alternative to loading one .npz file per sample:

  • Packer (diffusion_planner/data_pipeline/): converts an npz+sidecar tree into ~1 GB WebDataset-style tar shards (one safetensors.zst member per sample, bit-exact verified) plus a parquet manifest per partition. Partitions are immutable content-addressed revisions; a version is a small JSON file mapping partitions to revisions, published atomically; latest is a pointer. Adds/modifications/removals publish a new version without touching pinned ones. Includes inspect, pack, remove, prune-version, gc, scrub, export, keyset subcommands.
  • Manifest: index columns + the sidecar fields that already exist today, verbatim. No new per-sample fields.
  • Loader (utils/shard_plan.py, utils/shard_dataset.py, utils/shard_ddp.py): a DDP-safe IterableDataset with deterministic (rank, worker) planning, bounded padding, sequential shard streaming with a shuffle buffer, integrity checks on every read, and bounded retries. Emits the same 17-key batch dict as DiffusionPlannerData; model code is unchanged.
  • Training integration: --dataset_root/--dataset_version + a key-set or WHERE-clause selection per split. The existing --train_set_list npz path is unchanged.
  • Read API / export for analysis and legacy tools; recipe adapters proven equivalent (ordered multiset) to the existing path-list scripts on fixtures; validation scripts (mixing statistic, throughput, version lifecycle) and a real-data checklist.

Scope: mechanism only. No production data is packed, modified, or deleted by this PR; which data to pack is a separate team decision.

Design notes / deviations worth knowing

  • data_rev (shards) is derived from the tensor fingerprint + pack recipe (incl. shuffle seed); a sidecar-only change publishes only a new manifest (meta_rev), never a repack.
  • Version files are create-if-absent; identity ignores only created_at.
  • The coordinated abort on a loader fault does not issue a collective (a failed rank cannot rendezvous with peers blocked in DDP all-reduce); it tears down and re-raises, relying on the launcher to terminate the other ranks.
  • Planning runs identically on every rank (deterministic); a rank-0 scatter is a tracked follow-up for very large selections.
  • Validation drop_last=True drops nothing (every slot is padded to whole batches).

Testing

  • 100 new tests (tests/test_dp_*.py, tests/test_shard_*.py, tests/test_shard_train_config.py): bit-exact round-trips, corruption/truncation/mis-mapping detection, versioning atomicity and GC, planning invariants, skim-vs-seek equality, DDP-free loader behaviour, legacy-script equivalence via subprocess.
  • The 36 pre-existing failures in test_data_augmentation.py / test_closed_loop_*.py are unchanged (failure-id set identical before/after).
  • Real-data validation: done, and well beyond the planned slice. See "End-to-end reproduction" below.

Dependencies

pyarrow, duckdb, safetensors added to diffusion_planner/pyproject.toml (uv.lock updated).

End-to-end reproduction

The pipeline has been validated by converting a full production training corpus and training a
model through it end to end, then comparing against a reference model trained on the same data
via the existing npz path.

Conversion integrity. Every sample in the packed corpus was decoded and checksummed against
its source: zero mismatches. Manifest membership is exact in both directions (nothing missing,
nothing extra), and the partition count matches expectation. Train/validation key-sets are
disjoint, verified — the split is recoverable from a version tag rather than reconstructed by
convention.

Controlled training comparison. To isolate the data path, the run used the exact commit the
reference model was trained at
, resumed from that model's own checkpoint, with identical
hyperparameters. The only difference between the two runs is how samples reach the GPU.

Acceptance bands are derived from the reference run's own converged-plateau variance (2 sigma over
its final ten epochs) rather than chosen by hand, since diffusion training is stochastic and
pointwise equality is meaningless:

metric result
train_loss (10-epoch plateau mean) +0.16% vs reference — inside band
valid_loss_ego inside band
valid_loss_ego_position_lat_loss inside band

The candidate's own plateau standard deviation matched the reference's (0.001088 vs 0.001044), so
this is a matched distribution, not just a matched mean. Both learning-rate transitions in the
final phase reproduced (a ~10% step and a ~1.3% step, at the same epochs) — the two points where
the trajectories could most easily have separated.

Checkpoint comparison. Both final checkpoints were scored in the same process, on the same
held-out set, through the same (npz) evaluation path, so any difference is attributable to the
weights rather than the evaluation:

metric delta vs reference
aggregate driving score +0.08%
ego position L2 +0.14%
comfort −0.00%
no-at-fault-collision −0.13%
median across 67 comparable metrics 0.00%

Scope of the claim. This shows the shard pipeline trains equivalently to the npz path on
identical data. It is not a throughput result: on a dedicated box with local storage the loader
supplies ~30x what training consumes, so the run is compute-bound and no data-path change moves
wall-clock. The benefits realised here are portability, reproducible splits, and verifiability.

A tooling note for reviewers: data_pipeline/validation/compare_runs.py is the comparison tool
used above. It refuses to score a candidate whose epoch coverage does not match the baseline's, and
treats an empty or non-numeric metric cell as an error rather than silently averaging the remaining
epochs — without that, a run that diverged mid-plateau could report a passing mean.

…c execute(); guard ranked-handle composition
…-rank mixing, JS distance, RSS, gc set, explicit pad cap)
Shared run_query() in duck.py wraps ;-guard + parser/binder/catalog
exception -> PlanError conversion.  keyset.py and reader.py now use it.
pack_shards main() catches duckdb.Error + TimeoutError -> exit 1.
Removes --workers CLI flag and PackOptions.workers (unused).
…th report, --workers validation

Task 2 review fix round 1:
- Add test_run_builds_returns_job_order_not_completion_order: proves _run_builds
  reassembles results by job order, not completion order, using a module-level
  worker that sleeps inversely to job index.
- Add test_worker_death_publishes_nothing: exercises the BrokenProcessPool path
  via an os._exit(1) worker (the OOM-killer shape), asserting PackWorkerError
  and no publish.
- BrokenProcessPool handler no longer names a single (set-iteration-arbitrary)
  partition; reports the sorted set of partitions that were in flight instead.
- by_pid now keys off job.partition_id, not build.entry.partition_id, removing
  the dependence on the worker's returned identity (matters for the reuse
  branch).
- Remove dead job_of dict.
- pack_shards: reject --workers < 1; warn (non-fatal) when --workers exceeds
  os.cpu_count(). Covered by test_pack_rejects_workers_below_one.
- _Progress projects partition-completion progress onto sample counts
  (p50=242, max=12,221 samples/partition, so partition count alone is a
  poor proxy for work); PackOptions.progress (default True) selects it,
  falling back to _NullProgress otherwise.
- _run_builds' pool wait now carries a timeout (_HEARTBEAT_TIMEOUT_S,
  default 60s) instead of blocking forever on futures.wait; when it
  times out with nothing done, it fires on_done.heartbeat(inflight) so
  a hung worker is visible instead of silent, on a run expected to take
  hours over 10,926 partitions. _NullProgress makes this heartbeat call
  safe when progress is disabled.
- test_dp_cli.py: pack's default progress output now lands on stderr,
  which two existing keyset error-message tests were incidentally
  capturing alongside the setup pack() call; drain capsys after the
  setup call so those tests assert only the keyset command's own
  stderr, as they did before this feature existed.
…tion in pack()

I2 - the refill ex.submit(...) inside _run_builds's per-completed-future loop sat
outside the try/except wrapping fut.result(). When the pool has already broken by
the time a *different* already-successful future is drained and refilled (done is
a set; iteration order is not completion order), submit() itself raises
BrokenProcessPool synchronously, and that escaped _run_builds as a raw traceback
instead of the PackWorkerError the CLI, spec, and runbook abort criteria all
promise. Wrap the whole while inflight: body so both paths converge on the same
PackWorkerError contract.

I8 - --workers validation lived only in pack_shards.py's CLI. pack()/PackOptions
had none, so pack_bench (which builds PackOptions directly) accepted --workers 0
silently (serial fallback while reporting workers: 0) and could divide by
baseline_w == 0 downstream. Move the check into pack() itself; the CLI's early
check is unchanged.

Tests added and confirmed to fail against the pre-fix code (git apply -R / rerun
/ git apply) before being confirmed to pass with the fix.
…contract

C1 - verify_conversion's membership gate raised on ANY missing_from_manifest, but
packing defaults to dropping is_skipped frames, so on a real corpus the gate can
never pass and the decision moves to a human summing 'rejected' across batch logs
by eye. Add --allow-missing N (default 0, exact match required, not <=) and
--expected-partitions N; raise the printed-sample cap from 3 to 20 and print a
sample of the residual keys on both pass and failure, not just failure.

Minor - verify_offsets never cross-checked a manifest's own embedded metadata
(partition_id/data_rev/meta_rev/shards) against the version entry naming it, so a
manifest swapped in from a different build went unnoticed as long as row/member
counts stayed consistent. Added.

Minor - verify_conversion caught only IntegrityError (a wrong --tag gave a raw
FileNotFoundError); pack_bench caught nothing at all. Both now match
pack_shards' 'error: ...' / exit-1 contract.

Tests added; each confirmed to fail against the pre-fix code before the fix
restores a pass (verified via git apply -R / rerun / git apply).
Minor - progress was on by default with no opt-out. --quiet sets progress=False
in PackOptions; default behavior (progress on) is unchanged.
…n the runbook

I3 - the batch splitter built partition ids from a full-tree inspect walk, then
passed them as --partition while scoping discovery with --path-list. Since the
production path list is a subset of the tree, any batch containing a
source-only partition aborted with PlanError. Fix: a new path-list-scoped
partitions.tsv (2.3) feeds every downstream step that must agree with what will
actually be packed (3.1, 5.2, 6.3); the full-tree inspect table is kept only for
the pid-collision preflight (2.2), which deliberately wants full-corpus
coverage. The batch splitter (5.2) now also emits a per-batch path list, and the
batch loop (5.3) packs with --path-list and no --partition flags at all.

I4 - no set -o pipefail, and | tee masked pack's exit code, so a failed batch
still advanced prev_tag to a tag that was never published. Added set -o
pipefail and an explicit || { ...; exit 1; } after the tee pipeline.

I5 - pack_bench always measures a --path-list-scoped slice; production batches
used to discover the full list before the I3 fix. Updated 5.4's cost-model
section to say what r1 x W x eta_W does and does not include now: per-batch
discovery is back in scale with calibration, but _check_unique_keys (a full
re-read of every manifest in the accumulated base version, every batch) has no
term in the model and grows with progress -- documented as an expected,
non-alarming source of drift, distinct from a real shortfall.

I6 - Phase 4 (scrub + verify_offsets + loader smoke) is the heaviest read pass
in the procedure and had no host-safety guidance of its own. Added a note that
Phase 1's renice instruction and Phase 7's abort criteria already cover it by
wording, and spelled out why (full re-hash, a second full manifest re-read,
another shard read pass).

I7 - the runbook claimed prepare_path_list checks that entries 'exist under
--new-prefix'; the tool does no filesystem access at all. Corrected the
sentence, and added a hard precondition: 2.2's missing-sidecars count must be
exactly zero before a batch may pass --require-sidecars, since that gate only
rejects a batch after its shards are fully built.

Minor - added a 'reclaim disk with pack_shards gc' step to the abort section
(free space is itself an abort criterion, and _publish leaves an aborted
batch's build_dir behind); a note that Ctrl-C drains rather than stopping
immediately; and fixed 6.2's stale '§5.1' cross-reference to '§5.3'.
Minor - both directories are currently merely untracked, one 'git add -A' away
from committing internal design/process documents to a public repository.
PackOptions.source_namespace already existed and was consulted by the base
revision namespace check, but no CLI flag set it, so a relocated dataset
root could never be extended incrementally: the resolved --source path on
the new machine never matches the namespace recorded in the base revision,
and the only CLI escape (--replace-all) requires every source file to
still be present.

Add --source-namespace to the pack subcommand only, threaded into
PackOptions unchanged. Omitting the flag keeps today's behavior exactly
(source_namespace stays None, resolved --source is used).
data_rev/meta_rev/shards equality (and even (inode, mtime_ns) of the
published shard/manifest files) cannot distinguish the reuse short-circuit
from a deterministic rebuild landing on the identical revision: _publish
already refuses to overwrite an already-published shard directory that
verifies byte-identical, so both paths leave the same files with the same
stat behind (verified empirically by disabling the reuse short-circuit and
observing the stat-based assertion still pass).

Replace that with a monkeypatch spy on tar_shards.ShardWriter -- the
object pass 2 of _build_partition instantiates once per partition it
actually builds -- and assert it is never constructed for the reused
partition during the incremental pack. Confirmed this spy fails when the
reuse short-circuit is disabled (regression-verified by hand before
committing, packer.py left unchanged).
…er workers

shards_in_flight, shuffle_buffer, shuffle_buffer_bytes, shard_chunk_size,
shard_seek_threshold and shard_max_pad_fraction were plain dataclass fields,
so build_parser silently dropped them as CLI flags — even though
shard_plan.py's PlanError messages tell operators to pass a --chunk-size
flag that never existed. Expose all six via cli(), with defaults still
sourced from the same data_pipeline.defaults constants (purely additive:
omitting the flags reproduces today's behaviour exactly).

Add valid_num_workers (default 0 = inherit num_workers) so validation can
use fewer plan slots than training. Plan slots = world_size * num_workers,
and every slot pads up to a multiple of batch_size, so a validation set
much smaller than training wastes a large, irreducible padding fraction at
the same worker count. build_loaders now resolves the validation worker
count once and threads it into both the validation ShardDatasetConfig
(which sizes plan slots) and the validation DataLoader; training keeps
num_workers in both places. The resolution rule is factored into a small
pure helper, resolve_valid_num_workers, tested directly.
…erged candidate

Two compounding bugs let compare_runs print PASS on a candidate that never
actually reproduced the baseline:

- plateau_stats only required ONE in-range row, so a candidate that logged 3
  of 21 requested plateau epochs got averaged over those 3 and compared
  against a 21-epoch baseline mean.
- `r.get(column)` was a truthiness filter, so an empty cell silently
  vanished from the average. Real logs have empty cells: train.py's
  `DataFrame.to_csv(sep="\t")` renders NaN as "" (default na_rep), and
  ragged per-epoch keys (`**replan_agg`, `**mean_epdms_dict`) produce NaN
  for epochs that didn't compute a given metric.

plateau_stats now validates every in-range row explicitly: a missing/empty
or non-numeric cell raises ValueError naming the offending epoch, instead of
being dropped by the truthiness filter (removing that filter naively would
instead surface a raw TypeError from float(None) on a short/ragged row,
since csv.DictReader fills a missing trailing field with None rather than
""). compare() now also requires the candidate's in-range coverage (row
count and epoch min/max) to match the baseline's exactly, and reports both
counts on mismatch.

The printed line now shows, per metric, the row count and epoch range
actually used plus the candidate's own sd (previously discarded via
`c_mean, _`), so an operator can see what was actually averaged.

Also:
- main()'s except tuple now includes OSError (a directory path raised a raw
  IsADirectoryError; the same fix prepare_path_list.py already has).
- band()/compare() reject --k <= 0 (a negative k inverts the band, failing
  every metric with no explanation).
- module docstring documents the --valid_num_workers comparability hazard:
  two runs must share validation slot count or valid_loss_* carries a
  systematic shift from padded-duplicate double-counting (see
  validate_model.aggregate_valid_metrics).

New tests prove a PASS is now impossible for (i) a candidate missing
plateau epochs and (ii) a candidate with an empty/NaN cell in range, in
both plateau_stats and compare()/main(). Verified against the prior
implementation that both scenarios previously returned passed=True.
…ag names

--num_workers was a plain dataclass field (not cli()-marked), so build_parser
never emitted the flag: passing --num_workers on the command line raised an
argparse error, and the training worker count was effectively pinned at 8
on the CLI path — even though shard_max_pad_fraction's own help text already
told operators to "lower --num_workers/--valid_num_workers" to fix a
PlanError. num_workers is now a cli() field with its default unchanged (8).

utils/shard_plan.py's two PlanError messages advised "--chunk-size" and
"fewer workers", neither of which is a real flag; they now name
--shard_chunk_size and --num_workers/--valid_num_workers, matching the
flags pack_shards/train.py actually accept.

utils/shard_ddp.resolve_valid_num_workers only tested `> 0`, so a negative
--valid_num_workers was silently treated as "inherit --num_workers" instead
of being rejected. It now raises ValueError for any negative value.

Documentation-only: valid_num_workers' help text now states it applies to
the shard-loader path only (train.py's npz path always uses --num_workers
for both loaders, at train.py:296-307) and spells out the comparability
hazard: two runs must share --valid_num_workers or compare_runs' valid_loss_*
columns carry a systematic shift from padded-duplicate double-counting in
validate_model.aggregate_valid_metrics. No code change needed there per the
review — the hazard is inherent to how DistributedSampler padding is
aggregated, not a bug to fix.
…et root

RuleMismatchError's message only ever suggested --replace-all, which rebuilds
every partition from source files. That is the wrong remedy when a dataset
root has been relocated to another machine: the base recorded the original
host's resolved --source path as its namespace, the new host's --source can
never match it byte-for-byte, and --replace-all would require every source
file the base's partitions were built from to still be present — which a
relocation usually does not leave behind. --source-namespace already existed
as the correct fix for that case; the error message now names both remedies
and says when each applies.
test_pack_source_namespace_override_allows_relocated_dataset_root asserted
ShardWriter was never constructed during the reused-partition pack, but
installed the spy only right before that pack — so if packer.py ever
stopped routing through `tar_shards.T.ShardWriter` (e.g. switched to
`from .tar_shards import ShardWriter`), the spy would silently stop
intercepting anything and the "never constructed" assertion would pass for
the wrong reason.

The spy is now installed before the very first pack too, which has nothing
to reuse and must actually build the partition — asserted with
`len(shard_writer_calls) == 1` before the list is cleared for the later
reuse assertion. Verified this positive control actually catches the
regression it targets (temporarily rewired packer.py to call ShardWriter via
a direct import instead of `T.ShardWriter`; the new assertion failed with 0
calls instead of 1, whereas the old test would have passed vacuously).
…the runbook

conversion_runbook.md's Phase 4 (acceptance) never mentioned compare_runs,
the tool that decides whether a subsequent training run counts as a
reproduction of a baseline. Added §6.5 alongside the other validation CLIs
already documented there (verify_conversion, throughput_bench), including
the --valid_num_workers comparability hazard.

§5.3 (pack each batch, chaining --base) now calls out --source-namespace as
the fix when the dataset root itself is relocated mid-run to another
machine — the exact scenario chained --base packing runs into, since every
batch after such a move would otherwise fail its rule-mismatch guard.

realdata_checklist.md's parity-training step now points at compare_runs
instead of leaving "compare loss curves" as an unspecified manual step.
The module said the tool 'has no way to tell that two runs used different
validation settings'. That is not true: train.py writes args.json, which
records both worker counts, into the same directory as train_log.tsv, so a
sibling cross-check is available. Left as a documented opportunity rather
than an implemented guard, but a future reader should not conclude from the
docs that guarding this is impossible.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant