feat(datasets): stream training input from WebDataset tar shards - #1396
feat(datasets): stream training input from WebDataset tar shards#1396JESUSROYETH wants to merge 3 commits into
Conversation
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## develop #1396 +/- ##
========================================
Coverage 86% 86%
========================================
Files 114 115 +1
Lines 14877 15264 +387
========================================
+ Hits 12832 13201 +369
- Misses 2045 2063 +18 🚀 New features to boost your workflow:
|
06472a2 to
c1ad618
Compare
There was a problem hiding this comment.
Pull request overview
Adds opt-in WebDataset shard packing and streaming for detection and segmentation training.
Changes:
- Adds COCO-to-tar packing, shard indexing, streaming datasets, and loaders.
- Integrates WebDataset into configuration, data modules, CI, and dependencies.
- Adds comprehensive tests and user documentation.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
src/rfdetr/datasets/webdataset_io.py |
Implements packing, streaming, indexing, and loader construction. |
src/rfdetr/datasets/__init__.py |
Routes WebDataset creation. |
src/rfdetr/training/module_data.py |
Integrates streaming loaders into training and evaluation. |
src/rfdetr/config.py |
Adds the WebDataset format option. |
tests/datasets/test_webdataset_io.py |
Tests packing, streaming, transforms, and integration. |
pyproject.toml |
Adds the optional WebDataset dependency. |
.github/workflows/ci-tests-cpu.yml |
Installs WebDataset during CPU CI. |
docs/learn/train/dataset-formats.md |
Documents packing and training workflows. |
CHANGELOG.md |
Records the feature and its limitations. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
@JESUSROYETH, this is a great, detailed report. Could you pls alos inlcude TLDR section? Also, from the numbers, it seems that this did not bring any expected speedup? |
|
I think it may be worth exploring a more native WebDataset pipeline rather than constraining the implementation to the current Constraints
Why explore this?The current experiments suggest WebDataset itself may not be the bottleneck:
This suggests the workers can produce data substantially faster than the full loader delivers it, with the likely bottleneck around: So the ~1× result on local NVMe may not represent the performance ceiling of WebDataset. Local storage also doesn't appear I/O-bound, meaning faster tar reads alone wouldn't necessarily improve end-to-end throughput. Try more native WebDataset batchingIn particular, I'd test batching inside WebDataset: dataset = (
WebDataset(...)
.decode(...)
.map(...)
.batched(batch_size)
)
loader = DataLoader(
dataset,
batch_size=None,
...
)instead of sending individual samples through the normal This could reduce worker → main-process IPC/batching overhead and tell us whether the current integration is leaving performance on the table. Lightning / dataset-size constraintIf the main blocker to a more native WebDataset pipeline is that Lightning/RF-DETR needs a reliable For example, wrap/subclass the WebDataset pipeline and expose the known size: class SizedWebDataset(...):
def __init__(self, ..., size: int):
...
self.size = size
def __len__(self):
return self.sizeWe already know the number of samples when generating shards / reading dataset metadata, so this seems like a relatively small compatibility layer. Suggested experiment matrix
The goal isn't necessarily to make WebDataset faster than loose files on local NVMe—the current results suggest that case isn't I/O-bound. Rather, we should make sure the integration isn't introducing a PyTorch IPC/pinning bottleneck that hides WebDataset's potential benefits, while keeping zero/minimal local performance cost. |
|
@Borda Sounds good, I'll try the suggestion and get back to you. |
|
@Borda : Ran the full matrix. One Constraints
Experiment matrix
Also checked, since a surprise there would cost you review time: resume from checkpoint replays epoch 0's sample order at epoch 2 instead of continuing the sequence — and the map-style COCO path does the identical thing on the same probe, so it's existing resume behaviour rather than anything the shard path introduces. Where the headroom isThe hand-off, not the input format. The 112 per-sample target tensors are 3,592 bytes of a 65.81 MB batch — 0.005% of the payload — but each pays its own shared-memory allocation and fd round trip. Packing them into 7 takes the same harness from 329 to 565 img/s at 16 workers unpinned (1.72x), bit-identical output. It's downstream of the dataset and applies to the loose-file path too, so it isn't strictly about WebDataset — do you want it folded into this PR, or opened as a separate one? |
Seems the target works are quite independent with possible high yield, so let's focus on that one in another PR, and when it lands, we could integrate it here and finish it 🦝 |
|
@Borda : Revalidated this branch after #1399 and current
I agree that segmentation and keypoints should be follow-ups, but I suggest separating them by cause:
WebDataset keypoint support should remain a separate feature PR. It is currently explicitly unsupported because its label schema requires information that the shard index does not carry ( Actual multi-GPU/DDP execution remains unverified here; this validation was single-GPU and serial on an NVIDIA L4. |
Every streaming test failed on both windows-latest jobs while the same tests passed on ubuntu and macos. webdataset's StreamingOpen runs urlparse over each shard string and, for the empty and file schemes, opens urlparse(url).path verbatim — it neither percent-decodes nor converts URL syntax back to a native path. A bare str(path) parses C:\shards\train.tar as scheme "c", which reaches gopen with no handler registered and raises "no gopen handler defined". Path.as_uri() is no better: file:///C:/shards/train.tar leaves a path of /C:/shards/train.tar, and open rejects the leading slash with [Errno 22] Invalid argument. An authority-less file: URL over the forward-slash form of the path yields C:/shards/train.tar on Windows and /shards/train.tar elsewhere, both of which open takes as-is. Percent-encoding stays off on purpose, since nothing downstream reverses it.
Publication is now atomic where it claimed to be: shard names carry a generation token derived from the packed contents, so a re-pack writes alongside the shards the published index still references, the index is swapped with os.replace, and the previous generation is removed only after that commit. Deriving the token from the contents keeps re-packing unchanged data reproducible rather than churning the directory. Evaluation now applies the same rank-level shard guard as training: a rank left without a shard never reaches validation_step while populated ranks do, which deadlocks the process group rather than failing. A train/val category_ids mismatch is rejected instead of silently evaluating remapped labels against raw-trained predictions, pack_coco_to_shards validates category_ids for library callers, and a split name carrying a glob metacharacter is refused before it can match another split shards. Each change has a regression test.
96f4724 to
7918c18
Compare
|
Sounds like the file lock penalize too many workers, so wondering if we could have nb packets as batch size so we each worker could sample only one basket as only customer... |
TL;DR — No, it doesn't bring the speed-up #1392 expected, and I'd rather say that plainly. On local NVMe streaming shards match loose files within noise (0.92-1.03x) because that pipeline was never I/O-bound. Where it does pay off is object storage with a constrained worker budget: on GCS via gcsfuse it's 2.00x at 4 workers and 1.23x at 8, converging at 16. And it removes per-rank startup cost outright: 19.9 s -> 0.012 s and 2,810 MB -> 0.9 MB of resident memory. The ceiling that remains is the worker-to-main hand-off, which sits downstream of the dataset and no input format can move.
Why there's no speed-up on local NVMe
The two paths only differ up to the moment an image is decoded. After that — augmentation, collate, the hand-off,
pin_memory, Kornia — it's the same code on byte-identical tensors (verified: 500 real COCO images, 0 pixel and 0 box mismatches). So the most packing can win is the share of time spent getting bytes off the disk, and on this disk that share measured under 1%:iowaitnever exceeded 0.91%, one fully page-cached run read 0 MB and ran at the same speed as its cold twin, and a read-only benchmark sustained ~1,230 img/s against the ~210 the loader consumes. Sequential and random access are also equally fast here (0.99x), both converging on ~192 MB/s against a ~250 MB/s single-streamddceiling — a bandwidth limit, not an IOPS one. COCO's ~160 KB JPEGs are already large enough that there's no per-open()latency left to remove.So the null result isn't packing failing to work. It's packing optimising a stage that wasn't costing anything — on that hardware.
Implements the WebDataset pipeline from #1392: a packer, a streaming reader, the existing Albumentations functions mapped over the decoded stream in the CPU workers, and batched tensors handed to
pin_memoryand the existing Kornia GPU stage.It's opt-in through
dataset_file="webdataset".coco,roboflowandyolostay untouched.Changes
src/rfdetr/datasets/webdataset_io.py—pack_coco_to_shards()plus apython -m rfdetr.datasets.webdataset_ioCLI that writes ~100 MB.tarshards and a small JSON index;WebDatasetDetection, anIterableDatasetthat streams them;build_webdataset_loader().datasets/__init__.py,config.py— route and accept the newdataset_file.training/module_data.py— the four dataloader methods return the streaming loader for a streaming split, aligned tograd_accum_steps; a packedtestsplit is used when present;class_namescomes from the shard index, indexed by the same labels the loader emits under eithercategory_idspolicy; the private sample-grid helper rejects a split it cannot index.pyproject.toml—rfdetr[webdataset]extra, reader only..github/workflows/ci-tests-cpu.ymlinstalls it, otherwise the streaming half of the new tests sits behindimportorskipand silently skips.tests/datasets/test_webdataset_io.py,docs/learn/train/dataset-formats.md,CHANGELOG.md.Two decisions worth flagging
Packing uses the standard library's
tarfile, notwds.ShardWriter. A WebDataset shard is just an ordinary tar file whose members are grouped by basename, so writing one doesn't need anything from the package. That means the packer and its tests run in the default CI job instead of an extra-gated one, only the reader needs the dependency.The loader is a plain
DataLoader, notwds.WebLoader.WebLoaderbuilds exactly thatDataLoaderand then wraps it in aDataPipeline, which drops__len__and theDataLoadertype.len()on one raisesTypeError. RF-DETR needs that length:trainer.estimated_stepping_batchesdrives the LR schedule and the drop schedule, andWebLoaderonly offerswith_length(), which asserts a length without guaranteeing it. On top of that,WebLoaderadds a post-loaderunbatched().shuffle().batched()across workers, and that would break the same guarantee. The streaming behaviour comes from the pipeline inside the dataset, not from the loader class, so the result is the same either way. I'm happy to switch if you'd rather have the wrapper.Epoch sizing
Training gives each worker a fixed sample count, floored to a whole number of accumulation windows (
batch_size × grad_accum_steps). So the reported length equals the batches produced and no partial window reaches PTL — this is the streaming counterpart of the padding thatGradAccumAlignedDatasetalready gives the map-style loader for the same known PTL issue. Validation and test are different: each worker drains its shards once, so the split gets scored exactly once and those loaders report no length.Training refuses to start when
world_size × num_workersis bigger than the shard count. It also warns when an uneven shard split leaves the worst-served worker more than 5% short. That check is now measured from each shard's real sample count instead of assuming shards are uniform in sample count — they are cut by byte size, so that assumption can miss a real skew when image sizes vary (see the accuracy section for why this one matters).Scope of the distributed testing:
wds.split_by_nodeis exercised, but by two gloo ranks on CPU, not a real NCCL multi-GPU job. Both ranks completed training and validation; a split with fewer shards than ranks now fails fast with an actionable error instead of deadlocking the process group (previously only the training loader checked this, so evaluation could hang).Shard order and the in-stream reservoir buffer get reseeded every epoch from a per-worker epoch counter, not from
torch.initial_seed()alone. The reason: underpersistent_workers=True(the DataModule's own default whenevernum_workers > 0), a worker process is never restarted between epochs, so its torch seed stays the same, and a seed derived only from it would replay the first epoch's order forever. The counter lives on the dataset instance instead, which does survive across epochs inside a persistent worker, so it stays exact no matter the worker-restart policy.Throughput
On local NVMe there is no I/O bottleneck to remove, so these numbers report what the pipeline actually does rather than a speed-up claim. The object-storage case, where the bottleneck is real, is in the next section.
One GCP
g2-standard-32: 32 vCPU, L4, 125 GB RAM, 200 GB NVMe-attached persistent disk. COCO 2017train2017, 118,287 images, 19 GB loose, packed into 189 shards.RFDETRNanoat resolution 576,batch_size=16. Each cell is one process: 20 warm-up batches, then 400 timed batches, three repetitions, median with[min-max].colddrops the page cache before every repetition.Three checks from the same runs say the disk is not the limit.
iowaitnever goes above 0.91%. One fully page-cached configuration read 0 MB from disk and matched its cold twin. And a read-only benchmark on the same disk (no decode, no augmentation) sustains ~1,230 img/s against the ~210 the loader actually consumes, shards again landing at 0.99x loose.Both access patterns land around ~192 MB/s against a ~250 MB/s single-stream
ddceiling, so this disk is bandwidth-limited, not IOPS-limited. COCO's ~160 KB JPEGs are already big enough that there is no per-open()latency left for packing to remove.What is limiting them is the worker-to-main batch hand-off. Everything upstream stays identical; the only thing that shrinks is what crosses the process boundary (a collate that just returns the batch size, a measurement device, not something I'm proposing to ship),
num_workers=32:Decode and augmentation parallelize just fine. With the hand-off removed, going from 8 to 32 workers takes throughput from 412.7 to 778.9 img/s and CPU from 33% to 95%. With the hand-off in place, CPU stays near 33% no matter how many workers you add, which is the symptom in the issue, and it shows up on both input paths. (
pin_memory=Trueis still the right default here. This benchmark never touches the GPU, so it only sees the pinning cost, not the async host-to-device copy that pays for it.)Where the streaming path does win is construction time, and the gap scales with the annotation file, not with the images:
Three repetitions per arm, page cache dropped before each, median with
[min-max]:setup("fit")(MB)dataset_file="coco"dataset_file="webdataset"That's per process, so per rank under DDP — on 8 ranks, roughly 22 GB of host RAM and 160 s of startup that stop being spent. Packing pays off where per-file access is genuinely the constraint; that is measured below rather than assumed.
Loader throughput is not training throughput, so I also ran a real fine-tune on both paths:
RFDETRNano, 2 epochs over 2,000 train / 500 val images,batch_size=8, 8 workers, on the same L4. Wall clock was 172.7 s on loose files against 173.3 s on shards, and steady-state GPU utilisation 61.6% against 61.3% — no end-to-end regression on a local dataset. Both arms leave the GPU idle about 39% of the time, and that idle does not move when the input format changes: it is the same hand-off ceiling the loader table runs into.Object storage, where the bottleneck is real
Same 2,000-image split uploaded to a GCS bucket in the same region and mounted with gcsfuse, so the only difference from the rows above is the filesystem. Page cache dropped and gcsfuse's 60 s metadata TTL waited out before every run, so each is genuinely cold. Median of 3,
[min-max]:The ranges don't overlap at 4 or 8 workers. Both arms converge at 16 on ~220 img/s, which is the same ceiling the local-NVMe rows hit — so shards don't raise the ceiling, they let you reach it with fewer workers. At 4 workers the loose arm sits at 10.4% CPU: starved, waiting on per-file latency that parallelism would otherwise hide.
That worker count is not a corner case. This box has 32 vCPU, so a DDP job with 8 ranks leaves about 4 workers per rank — precisely the column where the gap is 2x. The construction saving above is also per rank, so both effects land on the same scenario rather than being separate curiosities.
On batching inside WebDataset
I tested
.batched(batch_size)withDataLoader(batch_size=None)as suggested: 1.02-1.06x acrossnum_workers4/8/16/32, with and without pinning — positive in all eight cells, with non-overlapping ranges in five of them, so the gain is real but small. What it does not do is move the ceiling, because it does not change what crosses the process boundary. PyTorch already callscollate_fninside the worker, so both arms hand the parent the same structure — I counted it: 114 tensors either way (the image, the padding mask, and 7 per-sample target tensors x 16)..batched()moves where the collate runs, not what gets shipped.So the integration isn't hiding WebDataset's benefit. The hand-off cost is real, but it is downstream of the dataset and independent of input format: holding the payload at 65.81 MB and sending it as 4 objects instead of 114 goes from 203.5 to 426.9 img/s, while cutting the bytes 3.25x at constant object count buys only 1.21x. That looks like its own issue rather than something to load onto this PR — happy to open it with the measurements if useful.
Accuracy
RFDETRNanofine-tuned for 3 epochs on the same 2,000-image split, once from loose files and once from shards, five seeds each,num_workers=8. Final-epochval/mAP_50_95:The middle row is a shard-count artefact, and finding it is exactly why the warning above exists. 39 shards over 8 workers splits 5,5,5,5,5,5,5,4, so the short worker is ~18% under the samples its fixed-length epoch asks for and wraps to fill, while better-supplied workers leave some unseen. Re-packing the identical split into 290 shards (0.6% under) moves the median from 0.3574 to 0.3685, onto the map-style 0.3681.
What that doesn't fix is the variance: the streaming arm runs 2-3x the seed-to-seed spread of the map-style one in both configurations. So, with an adequate shard count the medians match on this proxy. With a skewed one it costs ~0.011. The streaming path stays noisier across seeds either way, and parity on a full-length run over a real dataset is not verified here. A 3-epoch fine-tune on 2,000 images is a noisy proxy, and I'm not going to dress it up as more than that.
Validation
# doctest: +SKIPhelpers that need pytest fixture injection, a@pytest.fixtureor a realtmp_path, to run at all).cat2label,class_namesand per-image label sets. Both val loaders yield 500 samples and 500 unique image ids. Segmentation masks are covered the same way on a synthetic split with polygon annotations,include_masks=Trueon both sides: 0 mask mismatches.ci-tests-cpu.yml: 4351 passed, 24 failed, 79 skipped, 3 errors. Pristinedevelopat74bfc449gives 4259 passed, 24 failed, 71 skipped, 3 errors, and the 27FAILED/ERRORlines are identical in both: optional packages missing from my environment (roboflow,onnxscript,jsonargparse[signatures],onnxruntime), none of them in a file this PR touches.image_idorder of every training epoch, a run interrupted after epoch 1 and resumed fromlast.ckptreplays the sample order of epoch 0 at epoch 2 and of epoch 1 at epoch 3, rather than continuing the sequence — and the map-style COCO path does the identical thing on the same probe. So this is existing behaviour of the repo's resume, not something the shard path introduces; I mention it only so it isn't attributed to this PR.pre-commit run --all-files: 19/19 hooks,mypy --strictincluded.mkdocs buildsucceeds with the new section.uv lockanduv sync --no-default-groups --extra webdataset --python <V> --dry-runfor V in 3.10-3.14 all succeed.More defects were caught by running the code rather than reading it, each now with a regression test:
shardshuffle=Truemakes webdataset warn from every worker on every epoch (it wants an integer).webdatasetseeds its shuffle per generator call.torch.initial_seed()alone, which doesn't change across epochs whenpersistent_workers=True(the DataModule's own default whenevernum_workers > 0). So shard order and the reservoir buffer replayed the first epoch's draw forever under that config, instead of reshuffling. PyTorch still calls__iter__fresh on every epoch for a persistent worker, just without restarting the process, so a plain counter on the dataset instance survives across epochs too. Add it to whichever seed source doesn't already change on its own, and it fixes both cases.batch_size, notbatch_size × grad_accum_steps, so a partial accumulation window at the tail could still reach PTL. That's the exact bugGradAccumAlignedDatasetexists to prevent on the map-style path.image_idmatched no image were dropped silently. The packer only rejected the case where all image ids were orphaned.class_namesundercategory_ids="raw"returned a list sorted by id but indexed by position, not by id. Soclass_names[raw_label]could name the wrong category whenever the id range has a gap below the label being looked up. It's now indexed by id, matching how theremapbranch already worked (empty string at every unused slot). The docs'num_classesformula forraw(len(categories)) undercounts real COCO for the same reason: ids run 1-90 with gaps for 80 categories, so the model needs 91 output slots, not 80. Now it's stated asmax(category_id) + 1, matchingdataset_file="coco"'s existing convention.splitand an annotation'sfile_nameflowed into shard/index file paths and image reads with no validation, sosplit="../x"or a craftedfile_namecould write or read outside the intended directory. Both are now checked to resolve inside the shard/image directory.Not covered
Two gaps worth naming rather than leaving for review to find. The streaming path still shows 2-3x the seed-to-seed spread of the map-style loader (0.0138 and 0.0108 against 0.0046 over five seeds), and that extra variance was not isolated to a cause. And there is no verified accuracy parity on a full-length run over a real dataset — what exists is a 3-epoch, 2,000-image proxy. Distributed testing is gloo/CPU with two ranks, not NCCL multi-GPU.
Keypoint training rejects this format explicitly: its label space gets inferred from a whole parsed COCO annotation file, and a shard index doesn't carry that.
num_classeshas to be set on the model by hand (RFDETRSmall(num_classes=...)), because the auto-detection the other formats use looks fortrain/_annotations.coco.jsonordata.yamland finds neither. Undercategory_ids="remap"that number is the categories left after grouping nodes are dropped; under"raw"it's the highestcategory_idplus one. Class names need no such help. Shard paths resolve against the localdataset_dir; streaming straight from object storage is the obvious next step, but it's not wired up here.closes #1392