Skip to content

Commit 96f4724

Browse files
committed
fix(datasets): address review findings on the WebDataset shard path
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.
1 parent c1ad618 commit 96f4724

3 files changed

Lines changed: 241 additions & 15 deletions

File tree

docs/learn/train/dataset-formats.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -414,14 +414,16 @@ Repeat once per split, into the same `--output-dir`:
414414

415415
```
416416
coco-shards/
417-
├── train-000000.tar # ~100 MB each, override with --max-shard-mb
418-
├── train-000001.tar
417+
├── train-a1b2c3d4-000000.tar # ~100 MB each, override with --max-shard-mb
418+
├── train-a1b2c3d4-000001.tar
419419
├── ...
420420
├── train-index.json # shard list, sample count, categories
421-
├── val-000000.tar
421+
├── val-5e6f7a8b-000000.tar
422422
└── val-index.json
423423
```
424424

425+
The hex segment in a shard name is a generation token derived from the packed contents. It lets a re-pack write its shards alongside the ones the published index still points at, so the index swap is the only moment the pack changes; because it is derived from the contents rather than random, re-packing unchanged data reproduces the same names instead of churning the directory.
426+
425427
Image bytes are copied verbatim — no re-encode — so a packed split decodes to exactly the same pixels as the directory it came from. A `.json` member next to each image carries that image's `image_id`, `file_name` and annotation list, segmentation polygons included.
426428

427429
`--category-ids` chooses the label space, and the index records the choice so the loader never has to guess:

src/rfdetr/datasets/webdataset_io.py

Lines changed: 131 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
from __future__ import annotations
4040

4141
import argparse
42+
import hashlib
4243
import io
4344
import json
4445
import os
@@ -112,7 +113,9 @@ def _validate_split_name(split: str) -> str:
112113
*split*, unchanged, once validated.
113114
114115
Raises:
115-
ValueError: If *split* is empty, or contains a path separator or a ``.``/``..`` segment.
116+
ValueError: If *split* is empty, contains a path separator or a ``.``/``..`` segment, or carries a
117+
glob metacharacter. The last one matters because the name is also matched against existing shard
118+
files: a split called ``*`` would match — and then delete — the shards of every other split.
116119
117120
Examples:
118121
>>> _validate_split_name("train")
@@ -124,15 +127,23 @@ def _validate_split_name(split: str) -> str:
124127
"""
125128
if not split or "/" in split or "\\" in split or split in (".", ".."):
126129
raise ValueError(f"split {split!r} must not contain a path separator or '..'.")
130+
forbidden = sorted(set("*?[]") & set(split))
131+
if forbidden:
132+
raise ValueError(
133+
f"split {split!r} must not contain {''.join(forbidden)}: the name is matched against existing shard "
134+
"files, so a glob metacharacter would reach other splits' shards."
135+
)
127136
return split
128137

129138

130-
def _shard_name(split: str, index: int) -> str:
139+
def _shard_name(split: str, index: int, generation: str | None = None) -> str:
131140
"""Return the file name of shard *index* of *split*.
132141
133142
Args:
134143
split: Split name the shard belongs to.
135144
index: Zero-based shard number.
145+
generation: Token identifying one pack run. Included in the name so that re-packing a split writes new
146+
files instead of overwriting the ones the published index still points at.
136147
137148
Returns:
138149
Shard file name.
@@ -143,9 +154,13 @@ def _shard_name(split: str, index: int) -> str:
143154
Examples:
144155
>>> _shard_name("train", 7)
145156
'train-000007.tar'
157+
>>> _shard_name("train", 7, "a1b2c3d4")
158+
'train-a1b2c3d4-000007.tar'
146159
"""
147160
_validate_split_name(split)
148-
return f"{split}-{index:06d}.tar"
161+
if generation is None:
162+
return f"{split}-{index:06d}.tar"
163+
return f"{split}-{generation}-{index:06d}.tar"
149164

150165

151166
def index_name(split: str) -> str:
@@ -357,6 +372,56 @@ def _annotations_by_image(coco_data: dict[str, Any]) -> dict[Any, list[dict[str,
357372
return grouped
358373

359374

375+
def _pack_generation(payload: dict[str, Any], shard_sizes: list[int]) -> str:
376+
"""Derive a short generation token from what was packed.
377+
378+
Deterministic on purpose: the same split packed twice produces the same token, so re-packing unchanged data
379+
reproduces the same file names rather than churning the directory. Any change to the sample count, the
380+
category set, the per-shard sample counts or the shard byte sizes changes the token, which is what keeps a
381+
re-pack from writing over the shards a currently published index still references.
382+
383+
Args:
384+
payload: The index mapping for this pack, excluding the shard names it is about to name.
385+
shard_sizes: Byte size of each staged shard, in order.
386+
387+
Returns:
388+
Eight hex characters.
389+
390+
Examples:
391+
>>> a = _pack_generation({"split": "train", "num_samples": 2}, [10, 20])
392+
>>> a == _pack_generation({"split": "train", "num_samples": 2}, [10, 20])
393+
True
394+
>>> a == _pack_generation({"split": "train", "num_samples": 3}, [10, 20])
395+
False
396+
"""
397+
material = json.dumps({"index": payload, "sizes": shard_sizes}, sort_keys=True).encode("utf-8")
398+
return hashlib.sha256(material).hexdigest()[:8]
399+
400+
401+
def _published_shard_names(destination: Path, split: str) -> set[str]:
402+
"""Return the shard names the currently published index of *split* references.
403+
404+
Reading the index rather than globbing keeps cleanup scoped to what this split actually published: the
405+
directory legitimately holds other splits, and a half-written generation from a crashed run must not be
406+
mistaken for a live one.
407+
408+
Args:
409+
destination: Directory holding the pack.
410+
split: Split whose published index to read.
411+
412+
Returns:
413+
Shard file names from the published index, or an empty set when the split has no readable index yet.
414+
415+
Examples:
416+
>>> _published_shard_names(Path("/nonexistent"), "train")
417+
set()
418+
"""
419+
try:
420+
return set(read_shard_index(destination, split).shards)
421+
except (WebDatasetSplitUnavailableError, ValueError, KeyError, json.JSONDecodeError):
422+
return set()
423+
424+
360425
def pack_coco_to_shards(
361426
image_dir: str | Path,
362427
annotations_file: str | Path,
@@ -399,6 +464,10 @@ def pack_coco_to_shards(
399464
"""
400465
if max_shard_bytes <= 0:
401466
raise ValueError(f"max_shard_bytes must be > 0, got {max_shard_bytes}.")
467+
if category_ids not in ("remap", "raw"):
468+
# The CLI constrains this with `choices`, but a library caller can pass anything. Without this the pack
469+
# succeeds and only fails later, at read time, when ShardIndex.from_json rejects the policy it wrote.
470+
raise ValueError(f"category_ids must be 'remap' or 'raw', got {category_ids!r}.")
402471
_validate_split_name(split)
403472

404473
image_root = Path(image_dir)
@@ -494,6 +563,20 @@ def pack_coco_to_shards(
494563
tar = None
495564
shard_sample_counts.append(shard_samples)
496565

566+
provisional = list(shard_names)
567+
shard_sizes = [(work_dir / name).stat().st_size for name in provisional]
568+
generation = _pack_generation(
569+
{
570+
"split": split,
571+
"num_samples": written,
572+
"categories": list(coco_data.get("categories", ())),
573+
"annotated_category_ids": sorted(annotated_ids),
574+
"category_ids": category_ids,
575+
"samples_per_shard": shard_sample_counts,
576+
},
577+
shard_sizes,
578+
)
579+
shard_names = [_shard_name(split, position, generation) for position in range(len(provisional))]
497580
index = ShardIndex(
498581
split=split,
499582
shards=tuple(shard_names),
@@ -509,12 +592,20 @@ def pack_coco_to_shards(
509592
# this run does not reproduce (e.g. it produced fewer of them) are removed too, so no stale, unindexed
510593
# shard is left behind. The index is written last, so a reader can never observe an index whose shard
511594
# list is only partially on disk.
512-
stale_shards = {path.name for path in destination.glob(f"{split}-*.tar")} - set(shard_names)
513-
for name in shard_names:
514-
(work_dir / name).replace(destination / name)
515-
for name in stale_shards:
516-
(destination / name).unlink()
517-
(destination / index_name(split)).write_bytes(index_bytes)
595+
# Shard names carry this run's generation token, so moving them in cannot overwrite a shard the
596+
# published index still points at. Publication is therefore: move this generation's shards in, swap the
597+
# index with os.replace (atomic on POSIX), then drop the shards only the previous index referenced. A
598+
# reader that opened the old index keeps a complete pack until that last step; one that opens the new
599+
# index sees this generation in full. The window where a reader loses files it is mid-way through is
600+
# narrowed to the cleanup, not the whole re-pack.
601+
previous = _published_shard_names(destination, split)
602+
for staged, published in zip(provisional, shard_names):
603+
(work_dir / staged).replace(destination / published)
604+
staged_index = destination / f".{index_name(split)}.{generation}"
605+
staged_index.write_bytes(index_bytes)
606+
staged_index.replace(destination / index_name(split))
607+
for name in previous - set(shard_names):
608+
(destination / name).unlink(missing_ok=True)
518609
finally:
519610
if tar is not None:
520611
tar.close()
@@ -894,12 +985,24 @@ def build_webdataset_loader(
894985
A ``DataLoader`` over *dataset*.
895986
896987
Raises:
897-
ValueError: If a planned epoch would leave a worker with no shard to read.
988+
ValueError: If the split has fewer shards than distributed ranks, or if a planned epoch would leave a
989+
worker with no shard to read.
898990
"""
899991
_require_webdataset()
900992
ranks = _distributed_world_size() if world_size is None else world_size
993+
shard_count = len(dataset.index.shards)
994+
if ranks > shard_count:
995+
# An empty DataLoader *worker* is harmless; an entirely empty *rank* is not. split_by_node would leave
996+
# that rank with no batches, so it never enters validation_step/test_step while the populated ranks do —
997+
# and their DDP forward and sync_dist=True logging then wait on a rank that never arrives. This applies
998+
# to evaluation as much as to training, so it is checked before the fixed_epoch branch below.
999+
raise ValueError(
1000+
f"Split {dataset.index.split!r} has {shard_count} shard(s) for {ranks} rank(s): a rank with no shard "
1001+
"never reaches the step function while the others do, which deadlocks the process group. Re-pack "
1002+
"with a smaller --max-shard-mb so the split has at least one shard per rank."
1003+
)
9011004
if fixed_epoch:
902-
shards = len(dataset.index.shards)
1005+
shards = shard_count
9031006
slots = ranks * max(1, num_workers)
9041007
if slots > shards:
9051008
raise ValueError(
@@ -1019,7 +1122,23 @@ def build_webdataset(image_set: str, args: Any, resolution: int) -> WebDatasetDe
10191122
keypoint_flip_pairs=None,
10201123
)
10211124

1022-
cat2label = None if is_train else read_shard_index(root, "train").cat2label()
1125+
if is_train:
1126+
cat2label = None
1127+
else:
1128+
# Both indexes are read so a policy mismatch is refused rather than silently honoured. Passing None for a
1129+
# "raw" train split would let this split derive its own mapping from its own index, so a train split
1130+
# packed "raw" beside a val split packed "remap" would evaluate remapped labels against raw-trained
1131+
# predictions — wrong numbers, no error.
1132+
train_index = read_shard_index(root, "train")
1133+
split_index = read_shard_index(root, split)
1134+
if split_index.category_ids != train_index.category_ids:
1135+
raise ValueError(
1136+
f"Split {split!r} was packed with category_ids={split_index.category_ids!r} but the train split "
1137+
f"was packed with {train_index.category_ids!r}. The two label spaces do not match, so evaluation "
1138+
"would score predictions against different class indices than training used. Re-pack both splits "
1139+
"with the same --category-ids."
1140+
)
1141+
cat2label = train_index.cat2label()
10231142
logger.info("Building WebDataset %s dataset at resolution %d from %s", image_set, resolution, root)
10241143
return WebDatasetDetection(
10251144
root,

tests/datasets/test_webdataset_io.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@
3636
ShardIndex,
3737
WebDatasetDetection,
3838
WebDatasetSplitUnavailableError,
39+
_pack_generation,
3940
_shard_url,
41+
_validate_split_name,
4042
build_webdataset,
4143
build_webdataset_loader,
4244
index_name,
@@ -223,6 +225,68 @@ def test_partially_orphaned_annotations_are_rejected(self, tmp_path: Path) -> No
223225
with pytest.raises(ValueError, match="matching no image"):
224226
pack_coco_to_shards(image_dir, annotations, tmp_path / "shards")
225227

228+
@pytest.mark.parametrize(
229+
"category_ids",
230+
[pytest.param("contiguous", id="unknown-word"), pytest.param("", id="empty")],
231+
)
232+
def test_unknown_category_policy_is_rejected_before_anything_is_written(
233+
self, tmp_path: Path, category_ids: str
234+
) -> None:
235+
"""The CLI's choices= does not protect a library caller; without this the pack only fails at read time."""
236+
image_dir, annotations = _build_coco_split(tmp_path, count=2)
237+
shard_dir = tmp_path / "shards"
238+
with pytest.raises(ValueError, match="category_ids"):
239+
pack_coco_to_shards(image_dir, annotations, shard_dir, split="train", category_ids=category_ids)
240+
assert not shard_dir.exists() or not list(shard_dir.iterdir())
241+
242+
@pytest.mark.parametrize(
243+
"split",
244+
[pytest.param("*", id="star"), pytest.param("tr?in", id="question"), pytest.param("tr[ai]n", id="bracket")],
245+
)
246+
def test_glob_metacharacters_in_split_are_rejected(self, split: str) -> None:
247+
"""A split used as a glob would match, and then delete, other splits' shards."""
248+
with pytest.raises(ValueError, match="glob metacharacter|must not contain"):
249+
_validate_split_name(split)
250+
251+
def test_repacking_one_split_leaves_another_splits_shards_alone(self, tmp_path: Path) -> None:
252+
shard_dir = tmp_path / "shards"
253+
val_images, val_annotations = _build_coco_split(tmp_path, count=4, subdir="other")
254+
val_index = pack_coco_to_shards(val_images, val_annotations, shard_dir, split="val")
255+
val_bytes = {name: (shard_dir / name).read_bytes() for name in val_index.shards}
256+
257+
train_images, train_annotations = _build_coco_split(tmp_path, count=6, subdir="tr")
258+
pack_coco_to_shards(train_images, train_annotations, shard_dir, split="train")
259+
pack_coco_to_shards(train_images, train_annotations, shard_dir, split="train", max_shard_bytes=4096)
260+
261+
assert read_shard_index(shard_dir, "val").shards == val_index.shards
262+
for name, payload in val_bytes.items():
263+
assert (shard_dir / name).read_bytes() == payload
264+
265+
def test_a_repack_never_leaves_the_index_pointing_at_a_missing_shard(self, tmp_path: Path) -> None:
266+
"""Publication order must keep the index and the shards it names consistent at every step."""
267+
shard_dir = tmp_path / "shards"
268+
first_images, first_annotations = _build_coco_split(tmp_path, count=8, subdir="first")
269+
pack_coco_to_shards(first_images, first_annotations, shard_dir, split="train", max_shard_bytes=4096)
270+
271+
second_images, second_annotations = _build_coco_split(tmp_path, count=3, subdir="second")
272+
pack_coco_to_shards(second_images, second_annotations, shard_dir, split="train", max_shard_bytes=4096)
273+
274+
index = read_shard_index(shard_dir, "train")
275+
assert index.num_samples == 3
276+
for name in index.shards:
277+
assert (shard_dir / name).exists()
278+
# Nothing from the first generation is left behind unindexed.
279+
on_disk = {path.name for path in shard_dir.glob("train-*.tar")}
280+
assert on_disk == set(index.shards)
281+
282+
def test_generation_is_content_derived_so_repacking_is_reproducible(self, tmp_path: Path) -> None:
283+
image_dir, annotations = _build_coco_split(tmp_path, count=4)
284+
first = pack_coco_to_shards(image_dir, annotations, tmp_path / "a", split="train")
285+
second = pack_coco_to_shards(image_dir, annotations, tmp_path / "b", split="train")
286+
assert first.shards == second.shards
287+
assert _pack_generation({"x": 1}, [2]) == _pack_generation({"x": 1}, [2])
288+
assert _pack_generation({"x": 1}, [2]) != _pack_generation({"x": 2}, [2])
289+
226290
@pytest.mark.parametrize("max_shard_bytes", [pytest.param(0, id="zero"), pytest.param(-1, id="negative")])
227291
def test_non_positive_shard_size_is_rejected(self, tmp_path: Path, max_shard_bytes: int) -> None:
228292
image_dir, annotations = _build_coco_split(tmp_path, count=1)
@@ -664,6 +728,28 @@ def test_evaluation_loader_reports_no_length(self, tmp_path: Path) -> None:
664728
with pytest.raises(TypeError):
665729
len(loader)
666730

731+
@pytest.mark.parametrize(
732+
"fixed_epoch",
733+
[pytest.param(True, id="training"), pytest.param(False, id="evaluation")],
734+
)
735+
def test_fewer_shards_than_ranks_is_rejected_on_both_paths(self, tmp_path: Path, fixed_epoch: bool) -> None:
736+
"""A rank with no shard never reaches the step function while the others do, which deadlocks DDP.
737+
738+
Evaluation used to skip this check, so only training was protected.
739+
"""
740+
shard_dir = _pack(tmp_path, count=8)
741+
dataset = WebDatasetDetection(shard_dir, "train", transforms=None)
742+
shards = len(dataset.index.shards)
743+
with pytest.raises(ValueError, match="rank"):
744+
build_webdataset_loader(
745+
dataset,
746+
batch_size=2,
747+
collate_fn=_count_collate,
748+
num_workers=1,
749+
fixed_epoch=fixed_epoch,
750+
world_size=shards + 1,
751+
)
752+
667753
def test_more_workers_than_shards_is_rejected_for_training(self, tmp_path: Path) -> None:
668754
image_dir, annotations = _build_coco_split(tmp_path, count=16)
669755
shard_dir = tmp_path / "shards"
@@ -832,6 +918,25 @@ def test_both_resize_pipelines_produce_model_ready_tensors(
832918
assert image.ndim == 3
833919
assert target["boxes"].shape[-1] == 4
834920

921+
def test_category_policy_mismatch_between_splits_is_rejected(self, tmp_path: Path) -> None:
922+
"""Train packed raw beside val packed remap would score two different label spaces against each other."""
923+
shard_dir = tmp_path / "shards"
924+
train_images, train_annotations = _build_coco_split(tmp_path, count=6, subdir="tr")
925+
pack_coco_to_shards(train_images, train_annotations, shard_dir, split="train", category_ids="raw")
926+
val_images, val_annotations = _build_coco_split(tmp_path, count=4, subdir="va")
927+
pack_coco_to_shards(val_images, val_annotations, shard_dir, split="val", category_ids="remap")
928+
with pytest.raises(ValueError, match="category_ids"):
929+
build_webdataset("val", self._namespace(shard_dir), 224)
930+
931+
def test_matching_raw_policy_across_splits_is_accepted(self, tmp_path: Path) -> None:
932+
shard_dir = tmp_path / "shards"
933+
train_images, train_annotations = _build_coco_split(tmp_path, count=6, subdir="tr2")
934+
pack_coco_to_shards(train_images, train_annotations, shard_dir, split="train", category_ids="raw")
935+
val_images, val_annotations = _build_coco_split(tmp_path, count=4, subdir="va2")
936+
pack_coco_to_shards(val_images, val_annotations, shard_dir, split="val", category_ids="raw")
937+
dataset = build_webdataset("val", self._namespace(shard_dir), 224)
938+
assert dataset.cat2label is None
939+
835940
def test_build_dataset_routes_the_webdataset_format(self, tmp_path: Path) -> None:
836941
shard_dir = _pack(tmp_path, count=4)
837942
dataset = build_dataset("train", self._namespace(shard_dir), 224)

0 commit comments

Comments
 (0)