Skip to content

Commit eebddfa

Browse files
Boris PeyriguereBoris Peyriguere
authored andcommitted
Fix Objects365 DDP stream cardinality
1 parent d534eff commit eebddfa

8 files changed

Lines changed: 171 additions & 9 deletions

complexity/generative/detection/data.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,20 @@
2323
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".bmp", ".webp"}
2424

2525

26+
def _bound_and_split_stream(
27+
stream: Any,
28+
*,
29+
local_examples: int,
30+
rank: int,
31+
world_size: int,
32+
splitter: Any,
33+
) -> Any:
34+
"""Bound a lazy HF stream globally before attaching its rank split."""
35+
36+
bounded_stream = stream.take(local_examples * world_size)
37+
return splitter(bounded_stream, rank=rank, world_size=world_size)
38+
39+
2640
def _normalize_image(image: Image.Image) -> torch.Tensor:
2741
pixels = torch.from_numpy(np.array(image)).float().permute(2, 0, 1) / 255.0
2842
return (pixels - 0.5) / 0.5
@@ -284,12 +298,18 @@ def _stream(self, *, metadata_only: bool = False):
284298
seed=self.seed + self.epoch,
285299
buffer_size=self.shuffle_buffer,
286300
)
287-
stream = split_dataset_by_node(
301+
# Hugging Face applies the distributed split lazily at iteration time.
302+
# Calling ``take(local_examples)`` after attaching that split still
303+
# limits the *global* stream first, leaving only local/world examples
304+
# on each rank. Bound the global stream to an exactly divisible size,
305+
# then attach the rank split so every rank yields ``local_examples``.
306+
return _bound_and_split_stream(
288307
stream,
308+
local_examples=self.local_examples,
289309
rank=self.rank,
290310
world_size=self.world_size,
311+
splitter=split_dataset_by_node,
291312
)
292-
return stream.take(self.local_examples)
293313

294314
def _targets(self, row: Dict[str, Any]) -> torch.Tensor:
295315
annotations = row[self.annotations_column]

complexity/generative/detection/training.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -865,6 +865,39 @@ def _average_precision_from_matches(
865865
return float(((recall[changing + 1] - recall[changing]) * precision[changing + 1]).sum())
866866

867867

868+
def verify_loader_cardinality(
869+
loader: DataLoader,
870+
processed_batches: int,
871+
distributed: DistributedContext | None,
872+
*,
873+
phase: str,
874+
) -> None:
875+
"""Refuse silently truncated iterable streams on any DDP rank."""
876+
877+
expected_batches = len(loader)
878+
local = {
879+
"rank": 0 if distributed is None else distributed.rank,
880+
"processed": processed_batches,
881+
"expected": expected_batches,
882+
}
883+
reports = (
884+
[local]
885+
if distributed is None
886+
else distributed.all_gather_objects(local)
887+
)
888+
invalid = [
889+
report
890+
for report in reports
891+
if report["processed"] != report["expected"]
892+
]
893+
if invalid:
894+
details = ", ".join(
895+
f"rank {report['rank']}: {report['processed']}/{report['expected']}"
896+
for report in invalid
897+
)
898+
raise RuntimeError(f"{phase} loader ended before its declared cardinality ({details})")
899+
900+
868901
@torch.inference_mode()
869902
def evaluate_detector(
870903
model: TRHashObjectDetector,
@@ -892,7 +925,9 @@ def evaluate_detector(
892925
leave=False,
893926
disable=False if show_progress else True,
894927
)
928+
processed_batches = 0
895929
for pixel_values, targets in progress:
930+
processed_batches += 1
896931
autocast = torch.autocast("cuda", dtype=torch.bfloat16) if use_amp else nullcontext()
897932
with autocast:
898933
model_inputs = pixel_values.to(
@@ -921,6 +956,12 @@ def evaluate_detector(
921956
detection["labels"],
922957
image_targets,
923958
)
959+
verify_loader_cardinality(
960+
loader,
961+
processed_batches,
962+
distributed,
963+
phase="validation",
964+
)
924965
if distributed is not None and distributed.enabled:
925966
states = distributed.all_gather_objects(metrics.state_dict())
926967
metrics = DetectionMetricsAccumulator(
@@ -1655,7 +1696,9 @@ def write_checkpoint(
16551696
leave=False,
16561697
disable=not distributed.is_main,
16571698
)
1699+
processed_batches = batches_to_skip
16581700
for batch_index, (pixel_values, targets) in enumerate(progress, start=batches_to_skip):
1701+
processed_batches = batch_index + 1
16591702
pixel_values = pixel_values.to(device, non_blocking=device.type == "cuda")
16601703
if args.multi_scale_min:
16611704
choices = range(
@@ -1742,6 +1785,13 @@ def write_checkpoint(
17421785
if args.save_steps and step % args.save_steps == 0:
17431786
write_checkpoint(epoch=epoch, batch_in_epoch=batch_index + 1)
17441787

1788+
verify_loader_cardinality(
1789+
loader,
1790+
processed_batches,
1791+
distributed,
1792+
phase=f"training epoch {epoch + 1}",
1793+
)
1794+
17451795
should_validate = validation_loader is not None and should_validate_epoch(
17461796
epoch,
17471797
args.epochs,

configs/supervisor/tr_hash_detector_objects365_v06_specialized.conf

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,4 @@ killasgroup=true
1212
redirect_stderr=true
1313
stdout_logfile=/workspace/complexity-framework/artifacts/tr_hash_detector_objects365_v06_specialized.log
1414
stdout_logfile_maxbytes=0
15-
environment=HF_HOME="/workspace/.hf_home",HF_TOKEN_PATH="/root/.cache/huggingface/token",HF_XET_HIGH_PERFORMANCE="1",HF_XET_CHUNK_CACHE_SIZE_BYTES="0",HF_DOWNLOAD_WORKERS="16",OBJECTS365_LOCAL_DIR="/workspace/datasets/object365",OUTPUT="artifacts/detector_objects365_v06_specialized",EPOCHS="12",NPROC_PER_NODE="8",BATCH_SIZE_PER_GPU="24"
15+
environment=HF_HOME="/workspace/.hf_home",HF_TOKEN_PATH="/root/.cache/huggingface/token",HF_XET_HIGH_PERFORMANCE="1",HF_XET_CHUNK_CACHE_SIZE_BYTES="0",HF_DOWNLOAD_WORKERS="16",PYTORCH_CUDA_ALLOC_CONF="expandable_segments:True",OBJECTS365_LOCAL_DIR="/workspace/datasets/object365",OUTPUT="artifacts/detector_objects365_v06_specialized",EPOCHS="12",NPROC_PER_NODE="8",BATCH_SIZE_PER_GPU="16"

scripts/detector_checkpoint_status.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,11 @@ def latest_resumable_checkpoint(root: Path) -> Path | None:
3030
return max(candidates, key=_step_number, default=None)
3131

3232

33-
def checkpoint_status(root: Path, expected_epochs: int) -> tuple[int, Path | None]:
33+
def checkpoint_status(
34+
root: Path,
35+
expected_epochs: int,
36+
expected_steps: int | None = None,
37+
) -> tuple[int, Path | None]:
3438
checkpoint = latest_resumable_checkpoint(root)
3539
if checkpoint is None:
3640
return NOT_FOUND, None
@@ -56,6 +60,13 @@ def checkpoint_status(root: Path, expected_epochs: int) -> tuple[int, Path | Non
5660
print(f"invalid training cursor in {checkpoint}", file=sys.stderr)
5761
return INCOMPATIBLE, checkpoint
5862
if epoch == expected_epochs and batch_in_epoch == 0:
63+
if expected_steps is not None and state.get("step") != expected_steps:
64+
print(
65+
f"step budget mismatch in {checkpoint}: "
66+
f"saved={state.get('step')!r}, requested={expected_steps}",
67+
file=sys.stderr,
68+
)
69+
return INCOMPATIBLE, checkpoint
5970
return COMPLETE, checkpoint
6071
if 0 <= epoch < expected_epochs and batch_in_epoch >= 0:
6172
return INCOMPLETE, checkpoint
@@ -71,9 +82,14 @@ def main() -> int:
7182
parser = argparse.ArgumentParser()
7283
parser.add_argument("root", type=Path)
7384
parser.add_argument("--expected-epochs", type=int, required=True)
85+
parser.add_argument("--expected-steps", type=int, default=None)
7486
args = parser.parse_args()
7587

76-
status, checkpoint = checkpoint_status(args.root, args.expected_epochs)
88+
status, checkpoint = checkpoint_status(
89+
args.root,
90+
args.expected_epochs,
91+
args.expected_steps,
92+
)
7793
if checkpoint is not None:
7894
print(checkpoint)
7995
return status

scripts/vast_pretrain_detector_specialized_objects365.sh

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ export COPY_PASTE=0
2323
export RANDOM_ERASING=0
2424
export WORKERS=0
2525
export EPOCHS="${EPOCHS:-12}"
26+
export BATCH_SIZE_PER_GPU="${BATCH_SIZE_PER_GPU:-16}"
2627
export LR="${LR:-5.4e-3}"
2728
export WARMUP_STEPS="${WARMUP_STEPS:-1000}"
29+
export PYTORCH_CUDA_ALLOC_CONF="${PYTORCH_CUDA_ALLOC_CONF:-expandable_segments:True}"
2830

2931
exec scripts/vast_train_detector_specialized_coco.sh

scripts/vast_smoke_detector_specialized_objects365.sh

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,12 @@ set -euo pipefail
44
cd "${REPO_ROOT:-/workspace/complexity-framework}"
55

66
OBJECTS365_LOCAL_DIR="${OBJECTS365_LOCAL_DIR:-/workspace/datasets/object365}"
7-
MARKER="${OBJECTS365_SMOKE_MARKER:-$OBJECTS365_LOCAL_DIR/.tr-hash-specialized-smoke-v1-complete}"
8-
OUTPUT="${SMOKE_OUTPUT:-artifacts/detector_objects365_v06_specialized_local_smoke}"
7+
MARKER="${OBJECTS365_SMOKE_MARKER:-$OBJECTS365_LOCAL_DIR/.tr-hash-specialized-smoke-v2-complete}"
8+
OUTPUT="${SMOKE_OUTPUT:-artifacts/detector_objects365_v06_specialized_local_smoke_v2}"
9+
NPROC_PER_NODE="${NPROC_PER_NODE:-8}"
10+
BATCH_SIZE_PER_GPU="${BATCH_SIZE_PER_GPU:-16}"
11+
TRAIN_EXAMPLES="${SMOKE_TRAIN_EXAMPLES:-512}"
12+
EXPECTED_STEPS=$(( (TRAIN_EXAMPLES / NPROC_PER_NODE + BATCH_SIZE_PER_GPU - 1) / BATCH_SIZE_PER_GPU ))
913

1014
if [[ ! -f "$OBJECTS365_LOCAL_DIR/.download-complete" ]]; then
1115
echo "Objects365 snapshot must be complete before the local smoke test" >&2
@@ -20,12 +24,18 @@ echo "[smoke] validating local Objects365 decode, 8-GPU DDP and specialized loss
2024
OBJECTS365_LOCAL_DIR="$OBJECTS365_LOCAL_DIR" \
2125
OUTPUT="$OUTPUT" \
2226
EPOCHS=1 \
23-
HF_DETECTION_TRAIN_EXAMPLES="${SMOKE_TRAIN_EXAMPLES:-512}" \
27+
NPROC_PER_NODE="$NPROC_PER_NODE" \
28+
BATCH_SIZE_PER_GPU="$BATCH_SIZE_PER_GPU" \
29+
HF_DETECTION_TRAIN_EXAMPLES="$TRAIN_EXAMPLES" \
2430
HF_DETECTION_VALIDATION_EXAMPLES="${SMOKE_VALIDATION_EXAMPLES:-128}" \
2531
HF_DETECTION_SHUFFLE_BUFFER="${SMOKE_SHUFFLE_BUFFER:-512}" \
2632
EVAL_EVERY=1 \
2733
SAVE_STEPS=1000 \
2834
scripts/vast_run_detector_specialized_objects365.sh
2935

36+
PYTHON_BIN="${PYTHON_BIN:-/venv/main/bin/python}"
37+
"$PYTHON_BIN" scripts/detector_checkpoint_status.py \
38+
"$OUTPUT" --expected-epochs 1 --expected-steps "$EXPECTED_STEPS" >/dev/null
39+
3040
touch "$MARKER"
3141
echo "[smoke] passed: $MARKER"

tests/test_detector_pretraining_pipeline.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@
1616
collect,
1717
write_reports,
1818
)
19+
from scripts.detector_checkpoint_status import (
20+
COMPLETE,
21+
INCOMPATIBLE,
22+
checkpoint_status,
23+
)
1924

2025
PROJECT_ROOT = Path(__file__).parents[1]
2126
OBJECTS365_CHECKPOINT = "artifacts/detector_objects365_v06_specialized/best"
@@ -48,6 +53,7 @@ def test_objects365_stage_starts_the_complete_detector_from_scratch() -> None:
4853
assert "--hf-detection-num-classes 365" in command
4954
assert "--output artifacts/detector_objects365_v06_specialized" in command
5055
assert "--epochs 12" in command
56+
assert "--batch-size 16" in command
5157
assert "--mosaic 0" in command
5258
assert "--workers 0" in command
5359
assert "--detector-checkpoint" not in command
@@ -69,9 +75,11 @@ def test_objects365_pipeline_requires_local_ddp_smoke_before_long_run() -> None:
6975
assert pipeline.index("vast_smoke_detector_specialized_objects365.sh") < pipeline.index(
7076
"vast_run_detector_specialized_objects365.sh"
7177
)
72-
assert "HF_DETECTION_TRAIN_EXAMPLES=\"${SMOKE_TRAIN_EXAMPLES:-512}\"" in smoke
78+
assert 'TRAIN_EXAMPLES="${SMOKE_TRAIN_EXAMPLES:-512}"' in smoke
7379
assert "HF_DETECTION_VALIDATION_EXAMPLES=\"${SMOKE_VALIDATION_EXAMPLES:-128}\"" in smoke
7480
assert "EVAL_EVERY=1" in smoke
81+
assert "--expected-steps \"$EXPECTED_STEPS\"" in smoke
82+
assert "smoke-v2-complete" in smoke
7583
assert "touch \"$MARKER\"" in smoke
7684

7785
resumable = (
@@ -81,6 +89,26 @@ def test_objects365_pipeline_requires_local_ddp_smoke_before_long_run() -> None:
8189
assert "PYTHON_BIN=/venv/main/bin/python" in resumable
8290

8391

92+
def test_detector_checkpoint_status_can_enforce_exact_step_budget(tmp_path: Path) -> None:
93+
checkpoint = tmp_path / "step_000004"
94+
checkpoint.mkdir()
95+
torch.save(
96+
{
97+
"epoch": 1,
98+
"batch_in_epoch": 0,
99+
"total_epochs": 1,
100+
"step": 4,
101+
},
102+
checkpoint / "training_state.pt",
103+
)
104+
105+
assert checkpoint_status(tmp_path, 1, expected_steps=4) == (COMPLETE, checkpoint)
106+
assert checkpoint_status(tmp_path, 1, expected_steps=3) == (
107+
INCOMPATIBLE,
108+
checkpoint,
109+
)
110+
111+
84112
def test_coco_stage_refines_the_objects365_detector_by_default() -> None:
85113
command = _dry_run("scripts/vast_train_detector_specialized_coco.sh")
86114

tests/test_tr_hash_detector_training.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import torch
55
from PIL import Image
66
from safetensors.torch import save_file
7+
from torch.utils.data import DataLoader, TensorDataset
78

89
from complexity.generative.detection import (
910
CocoDetectionDataset,
@@ -22,6 +23,7 @@
2223
load_training_state,
2324
save_training_state,
2425
)
26+
from complexity.generative.detection.data import _bound_and_split_stream
2527
from complexity.generative.detection.distributed import DistributedEvalSampler
2628
from complexity.generative.detection.training import (
2729
_average_precision_from_matches,
@@ -33,6 +35,7 @@
3335
resize_detector_inputs,
3436
save_object_bucket_count_cache,
3537
should_validate_epoch,
38+
verify_loader_cardinality,
3639
vision_backend_summary,
3740
)
3841

@@ -322,6 +325,39 @@ def test_hf_detection_dataset_resolves_local_parquet_glob(tmp_path):
322325
)
323326

324327

328+
def test_hf_detection_stream_bounds_globally_before_rank_sharding():
329+
operations = []
330+
331+
class FakeStream:
332+
def take(self, count):
333+
operations.append(("take", count))
334+
return self
335+
336+
def split(stream, *, rank, world_size):
337+
operations.append(("split", rank, world_size))
338+
return stream
339+
340+
stream = FakeStream()
341+
result = _bound_and_split_stream(
342+
stream,
343+
local_examples=4,
344+
rank=2,
345+
world_size=4,
346+
splitter=split,
347+
)
348+
349+
assert result is stream
350+
assert operations == [("take", 16), ("split", 2, 4)]
351+
352+
353+
def test_loader_cardinality_guard_rejects_silent_truncation():
354+
loader = DataLoader(TensorDataset(torch.arange(4)), batch_size=2)
355+
356+
verify_loader_cardinality(loader, 2, None, phase="test")
357+
with pytest.raises(RuntimeError, match="test loader ended"):
358+
verify_loader_cardinality(loader, 1, None, phase="test")
359+
360+
325361
def test_synthetic_dataset_deterministic_across_instances():
326362
first = SyntheticShapesDataset(length=4, image_size=64, seed=7)
327363
second = SyntheticShapesDataset(length=4, image_size=64, seed=7)

0 commit comments

Comments
 (0)