Fix rank-local dataloader checkpoint resume

This commit is contained in:
H1yori233
2026-07-22 21:29:48 -07:00
parent c0cb2228ac
commit 41dcd065ae
6 changed files with 862 additions and 4 deletions
@@ -211,6 +211,107 @@ def _contiguous_row_group_shards(row_groups: list[dict[str, Any]], num_shards: i
return shards
def reconstruct_streaming_dataset_state(
manifest: dict[str, Any],
*,
global_rank: int,
world_size: int,
sp_world_size: int,
num_workers: int,
batch_size: int,
read_batch_size: int,
seed: int,
shuffle_row_groups: bool,
yielded_samples: int,
worker_id: int = 0,
) -> dict[str, Any]:
"""Reconstruct an epoch-zero streaming cursor without reading Parquet.
This is intentionally strict and is meant for audited migration of legacy
checkpoints whose rank-local dataloader state was incorrectly stored in a
shared DCP key. It refuses epoch rollover because the exact wrapper state
at that boundary depends on whether the iterator has observed StopIteration.
"""
world_size = int(world_size)
sp_world_size = int(sp_world_size)
global_rank = int(global_rank)
num_workers = max(int(num_workers), 1)
batch_size = int(batch_size)
read_batch_size = int(read_batch_size)
yielded_samples = int(yielded_samples)
worker_id = int(worker_id)
if world_size < 1 or sp_world_size < 1 or world_size % sp_world_size:
raise ValueError("world_size must be positive and divisible by sp_world_size")
if not 0 <= global_rank < world_size:
raise ValueError("global_rank is outside the requested world")
if not 0 <= worker_id < num_workers:
raise ValueError("worker_id is outside the requested worker layout")
if batch_size < 1 or read_batch_size < 1:
raise ValueError("batch_size and read_batch_size must be positive")
if yielded_samples < 0 or yielded_samples % batch_size:
raise ValueError("yielded_samples must be a non-negative batch multiple")
row_groups = manifest.get("row_groups")
if not isinstance(row_groups, list) or not row_groups:
raise ValueError("Streaming manifest has no row groups")
num_sp_groups = world_size // sp_world_size
shards = _contiguous_row_group_shards(
row_groups,
num_sp_groups * num_workers,
)
shard_rows = [sum(int(item["rows"]) for item in shard) for shard in shards]
samples_per_worker = min(shard_rows) // batch_size * batch_size
if samples_per_worker == 0:
raise ValueError("Dataset is too small for the requested topology")
if yielded_samples >= samples_per_worker:
raise ValueError(
"Legacy cursor reconstruction only supports the first epoch and "
"requires yielded_samples < samples_per_worker"
)
sp_group_index = global_rank // sp_world_size
shard_index = sp_group_index * num_workers + worker_id
shard = list(shards[shard_index])
if shuffle_row_groups:
random.Random(int(seed)).shuffle(shard)
row_group_position = 0
row_offset = 0
remaining = yielded_samples
if remaining:
for position, item in enumerate(shard):
rows = int(item["rows"])
if remaining <= rows:
row_group_position = position
row_offset = remaining
break
remaining -= rows
else:
raise ValueError("yielded_samples exceeds the selected streaming shard")
return {
"version": _STATE_VERSION,
"manifest_fingerprint": str(manifest["fingerprint"]),
"topology": {
"global_rank": global_rank,
"world_size": world_size,
"sp_world_size": sp_world_size,
"sp_group_index": sp_group_index,
"num_workers": num_workers,
"batch_size": batch_size,
"read_batch_size": read_batch_size,
"seed": int(seed),
"shuffle_row_groups": bool(shuffle_row_groups),
"samples_per_worker": samples_per_worker,
},
"worker_id": worker_id,
"epoch": 0,
"row_group_position": row_group_position,
"row_offset": row_offset,
"yielded_samples": yielded_samples,
}
class LatentsParquetStreamingDataset(IterableDataset):
"""Sequential row-group loader with deterministic DP/SP sharding.
@@ -112,6 +112,57 @@ def test_state_dict_resumes_at_exact_next_batch(
assert [item["id"] for item in actual] == [item["id"] for item in expected]
def test_reconstructed_state_matches_continuous_cursor(
parquet_root: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_patch_dist(monkeypatch)
continuous = _dataset(parquet_root, tmp_path / "owned-cache")
iterator = iter(continuous)
for _ in range(3):
next(iterator)
expected_state = continuous.state_dict()
expected_next = next(iterator)["info_list"]
reconstructed_state = streaming.reconstruct_streaming_dataset_state(
continuous.manifest,
global_rank=0,
world_size=1,
sp_world_size=1,
num_workers=0,
batch_size=2,
read_batch_size=2,
seed=42,
shuffle_row_groups=False,
yielded_samples=6,
)
assert reconstructed_state == expected_state
resumed = _dataset(parquet_root, tmp_path / "owned-cache")
resumed.load_state_dict(reconstructed_state)
actual_next = next(iter(resumed))["info_list"]
assert [item["id"] for item in actual_next] == [item["id"] for item in expected_next]
def test_reconstructed_state_refuses_epoch_boundary(
parquet_root: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_patch_dist(monkeypatch)
dataset = _dataset(parquet_root, tmp_path / "owned-cache")
with pytest.raises(ValueError, match="first epoch"):
streaming.reconstruct_streaming_dataset_state(
dataset.manifest,
global_rank=0,
world_size=1,
sp_world_size=1,
num_workers=0,
batch_size=2,
read_batch_size=2,
seed=42,
shuffle_row_groups=False,
yielded_samples=dataset.samples_per_worker,
)
def test_odd_row_group_tails_are_carried_into_next_group(
parquet_root: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
@@ -14,6 +14,10 @@ from pathlib import Path
from typing import Any
import pytest
import torch
import torch.distributed.checkpoint as dcp
import fastvideo.train.utils.checkpoint as checkpoint_module
from fastvideo.train.utils.checkpoint import (
CheckpointConfig,
@@ -86,6 +90,44 @@ class _MissingLoad:
return {}
class _Method:
def checkpoint_state(self) -> dict[str, Any]:
return {"method": _Full()}
class _RecordingStateful:
def __init__(self, state: dict[str, Any] | None = None) -> None:
self.state = dict(state or {})
self.loaded: dict[str, Any] | None = None
def state_dict(self) -> dict[str, Any]:
return dict(self.state)
def load_state_dict(self, state: dict[str, Any]) -> None:
self.loaded = dict(state)
class _LoaderWithDataset(_RecordingStateful):
def __init__(self) -> None:
super().__init__()
self.dataset = _RecordingStateful()
class _TensorStateful:
def __init__(self, value: int) -> None:
self.value = torch.tensor([value], dtype=torch.int64)
def state_dict(self) -> dict[str, Any]:
return {"value": self.value}
def load_state_dict(self, state: dict[str, Any]) -> None:
self.value = state["value"]
def test_is_stateful_true_for_full_object() -> None:
assert _is_stateful(_Full()) is True
@@ -98,6 +140,150 @@ def test_is_stateful_false_when_missing_load_state_dict() -> None:
assert _is_stateful(_MissingLoad()) is False
def test_dataloader_is_not_part_of_shared_dcp_state(tmp_path: Path) -> None:
manager = CheckpointManager(
method=_Method(),
dataloader=_RecordingStateful({"cursor": 7}),
output_dir=str(tmp_path),
config=CheckpointConfig(save_steps=1, keep_last=0),
)
assert set(manager._build_states()) == {"method"}
def test_dcp_partial_load_ignores_legacy_dataloader_key(tmp_path: Path) -> None:
checkpoint_dir = tmp_path / "dcp"
dcp.save(
{
"method": _TensorStateful(7),
"dataloader": _TensorStateful(99),
},
checkpoint_id=str(checkpoint_dir),
)
restored = _TensorStateful(-1)
dcp.load({"method": restored}, checkpoint_id=str(checkpoint_dir))
assert restored.value.item() == 7
def test_legacy_single_rank_dataloader_dcp_fallback(tmp_path: Path) -> None:
checkpoint_dir = _make_checkpoint_dir(tmp_path, 10)
dcp.save(
{"dataloader": _TensorStateful(42)},
checkpoint_id=str(checkpoint_dir / "dcp"),
)
restored = _TensorStateful(-1)
manager = CheckpointManager(
method=_Method(),
dataloader=restored,
output_dir=str(tmp_path),
config=CheckpointConfig(save_steps=1, keep_last=0),
)
manager._load_dataloader_snapshot(checkpoint_dir, 10)
assert restored.value.item() == 42
def test_rank_local_dataloader_sidecar_roundtrip(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(checkpoint_module, "_rank", lambda: 3)
monkeypatch.setattr(checkpoint_module, "_world_size", lambda: 8)
checkpoint_dir = _make_checkpoint_dir(tmp_path, 20)
original = _RecordingStateful({"cursor": 11})
manager = CheckpointManager(
method=_Method(),
dataloader=original,
output_dir=str(tmp_path),
config=CheckpointConfig(save_steps=1, keep_last=0),
)
manager._save_dataloader_snapshot(checkpoint_dir, 20)
path = checkpoint_dir / "dataloader_state_rank3.pt"
assert path.is_file()
assert not list(checkpoint_dir.glob(".*.tmp"))
restored = _RecordingStateful()
restored_manager = CheckpointManager(
method=_Method(),
dataloader=restored,
output_dir=str(tmp_path),
config=CheckpointConfig(save_steps=1, keep_last=0),
)
restored_manager._load_dataloader_snapshot(checkpoint_dir, 20)
assert restored.loaded == {"cursor": 11}
def test_dataset_only_migration_sidecar_uses_loader_dataset(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(checkpoint_module, "_rank", lambda: 1)
monkeypatch.setattr(checkpoint_module, "_world_size", lambda: 4)
checkpoint_dir = _make_checkpoint_dir(tmp_path, 10)
migration_dir = tmp_path / "migration"
migration_dir.mkdir()
torch.save(
{
"version": 1,
"rank": 1,
"world_size": 4,
"step": 10,
"state_kind": "dataset",
"state": {"cursor": 99},
},
migration_dir / "dataloader_state_rank1.pt",
)
monkeypatch.setenv("FASTVIDEO_DATALOADER_STATE_DIR", str(migration_dir))
loader = _LoaderWithDataset()
manager = CheckpointManager(
method=_Method(),
dataloader=loader,
output_dir=str(tmp_path),
config=CheckpointConfig(save_steps=1, keep_last=0),
)
manager._load_dataloader_snapshot(checkpoint_dir, 10)
assert loader.loaded is None
assert loader.dataset.loaded == {"cursor": 99}
def test_dataloader_sidecar_metadata_mismatch_is_rejected(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(checkpoint_module, "_rank", lambda: 0)
monkeypatch.setattr(checkpoint_module, "_world_size", lambda: 2)
checkpoint_dir = _make_checkpoint_dir(tmp_path, 10)
torch.save(
{
"version": 1,
"rank": 1,
"world_size": 2,
"step": 10,
"state_kind": "dataloader",
"state": {},
},
checkpoint_dir / "dataloader_state_rank0.pt",
)
manager = CheckpointManager(
method=_Method(),
dataloader=_RecordingStateful(),
output_dir=str(tmp_path),
config=CheckpointConfig(save_steps=1, keep_last=0),
)
with pytest.raises(ValueError, match="rank mismatch"):
manager._load_dataloader_snapshot(checkpoint_dir, 10)
def test_missing_rank_local_dataloader_sidecar_fails_closed(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(checkpoint_module, "_world_size", lambda: 2)
checkpoint_dir = _make_checkpoint_dir(tmp_path, 10)
manager = CheckpointManager(
method=_Method(),
dataloader=_RecordingStateful(),
output_dir=str(tmp_path),
config=CheckpointConfig(save_steps=1, keep_last=0),
)
with pytest.raises(RuntimeError, match="Legacy multi-rank DCP dataloader state is unsafe"):
manager._load_dataloader_snapshot(checkpoint_dir, 10)
# ---------------------------------------------------------------------------
# B. _parse_step_from_dir
# ---------------------------------------------------------------------------
+144 -4
View File
@@ -7,6 +7,7 @@ import os
import random
import re
import shutil
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@@ -27,6 +28,8 @@ from fastvideo.logger import init_logger
logger = init_logger(__name__)
_CHECKPOINT_DIR_RE = re.compile(r"^checkpoint-(\d+)$")
_DATALOADER_STATE_VERSION = 1
_DATALOADER_STATE_OVERRIDE_ENV = "FASTVIDEO_DATALOADER_STATE_DIR"
def _is_stateful(obj: Any) -> bool:
@@ -39,11 +42,36 @@ def _rank() -> int:
return 0
def _world_size() -> int:
if dist.is_available() and dist.is_initialized():
return int(dist.get_world_size())
return 1
def _barrier() -> None:
if dist.is_available() and dist.is_initialized():
dist.barrier()
def _atomic_torch_save(payload: Any, path: Path) -> None:
"""Atomically publish a rank-local torch payload."""
path.parent.mkdir(parents=True, exist_ok=True)
fd, temporary_path = tempfile.mkstemp(
dir=path.parent,
prefix=f".{path.name}.",
suffix=".tmp",
)
try:
with os.fdopen(fd, "wb") as handle:
torch.save(payload, handle)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary_path, path)
finally:
if os.path.exists(temporary_path):
os.unlink(temporary_path)
def _parse_step_from_dir(checkpoint_dir: Path) -> int:
match = _CHECKPOINT_DIR_RE.match(checkpoint_dir.name)
if not match:
@@ -214,10 +242,6 @@ class CheckpointManager:
def _build_states(self) -> dict[str, Any]:
states: dict[str, Any] = self.method.checkpoint_state()
# Dataloader (optional but recommended for exact resume).
if _is_stateful(self.dataloader):
states["dataloader"] = self.dataloader
# Callback state (e.g. EMA shadow weights, validation RNG).
if self._callbacks is not None and _is_stateful(self._callbacks):
states["callbacks"] = _CallbackStateWrapper(self._callbacks, )
@@ -263,6 +287,12 @@ class CheckpointManager:
dcp.save(states, checkpoint_id=str(dcp_dir))
_barrier()
# Dataloader state is rank-local. Saving it under one shared DCP key
# lets DCP deduplicate fields from different ranks into an invalid
# chimera. Keep one atomic sidecar per rank instead.
self._save_dataloader_snapshot(checkpoint_dir, step)
_barrier()
if self._callbacks is not None:
self._callbacks.on_checkpoint_save(
self.method,
@@ -325,6 +355,115 @@ class CheckpointManager:
checkpoint_dir / f"rng_state_rank{rank}.pt",
)
def _save_dataloader_snapshot(
self,
checkpoint_dir: Path,
step: int,
) -> None:
if not _is_stateful(self.dataloader):
return
rank = _rank()
payload = {
"version": _DATALOADER_STATE_VERSION,
"rank": rank,
"world_size": _world_size(),
"step": int(step),
"state_kind": "dataloader",
"state": self.dataloader.state_dict(),
}
path = checkpoint_dir / f"dataloader_state_rank{rank}.pt"
_atomic_torch_save(payload, path)
logger.info("Saved rank-local dataloader state to %s", path)
def _resolve_dataloader_snapshot(
self,
checkpoint_dir: Path,
) -> Path | None:
rank = _rank()
default_path = checkpoint_dir / f"dataloader_state_rank{rank}.pt"
if default_path.is_file():
return default_path
override_dir = os.environ.get(_DATALOADER_STATE_OVERRIDE_ENV, "").strip()
if override_dir:
override_path = (Path(os.path.expanduser(override_dir)).resolve() /
f"dataloader_state_rank{rank}.pt")
if override_path.is_file():
return override_path
raise FileNotFoundError(
f"{_DATALOADER_STATE_OVERRIDE_ENV} is set, but rank {rank} "
f"state is missing: {override_path}"
)
# A single-rank legacy DCP cannot mix state from different ranks, so
# loading its original dataloader key remains safe and preserves
# backwards compatibility.
if _world_size() == 1:
return None
raise RuntimeError(
"Checkpoint has no rank-local dataloader state sidecar. Legacy "
"multi-rank DCP dataloader state is unsafe because identically "
"named rank-local fields can be deduplicated across ranks. "
f"Create audited migration sidecars and set {_DATALOADER_STATE_OVERRIDE_ENV}."
)
def _load_dataloader_snapshot(
self,
checkpoint_dir: Path,
step: int,
) -> None:
if not _is_stateful(self.dataloader):
return
path = self._resolve_dataloader_snapshot(checkpoint_dir)
if path is None:
logger.warning(
"Loading legacy single-rank dataloader state from DCP at %s",
checkpoint_dir,
)
dcp.load(
{"dataloader": self.dataloader},
checkpoint_id=str(checkpoint_dir / "dcp"),
)
return
payload = torch.load(path, map_location="cpu", weights_only=False)
if not isinstance(payload, dict):
raise ValueError(f"Invalid dataloader state payload: {path}")
expected = {
"version": _DATALOADER_STATE_VERSION,
"rank": _rank(),
"world_size": _world_size(),
"step": int(step),
}
for field, expected_value in expected.items():
actual = payload.get(field)
if actual != expected_value:
raise ValueError(
f"Dataloader state {field} mismatch in {path}: "
f"expected {expected_value!r}, got {actual!r}"
)
state_kind = payload.get("state_kind")
state = payload.get("state")
if not isinstance(state, dict):
raise ValueError(f"Dataloader state is not a dictionary: {path}")
if state_kind == "dataloader":
self.dataloader.load_state_dict(state)
elif state_kind == "dataset":
dataset = getattr(self.dataloader, "dataset", None)
if not _is_stateful(dataset):
raise ValueError(
"Dataset-only migration state requires a stateful "
f"dataloader.dataset: {path}"
)
dataset.load_state_dict(state)
else:
raise ValueError(
f"Unsupported dataloader state_kind {state_kind!r} in {path}"
)
logger.info("Restored rank-local dataloader state from %s", path)
def load_rng_snapshot(
self,
checkpoint_path: str,
@@ -388,6 +527,7 @@ class CheckpointManager:
states = self._build_states()
logger.info("Loading Phase 2 checkpoint from %s", resolved)
dcp.load(states, checkpoint_id=str(resolved / "dcp"))
self._load_dataloader_snapshot(resolved, step)
if self._callbacks is not None:
self._callbacks.on_checkpoint_load(
self.method,
@@ -0,0 +1,227 @@
# SPDX-License-Identifier: Apache-2.0
"""Create audited rank-local dataloader sidecars for a legacy checkpoint.
The legacy checkpoint is never modified. This utility only supports the
deterministic, single-process-per-rank OpenVid streaming layout used by the
GB200 run and deliberately refuses ambiguous epoch-boundary migrations.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import torch
import yaml
from fastvideo.dataset.parquet_dataset_streaming_style import (
reconstruct_streaming_dataset_state,
)
from fastvideo.train.utils.checkpoint import (
_DATALOADER_STATE_VERSION,
_atomic_torch_save,
)
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _nested(mapping: dict[str, Any], *keys: str) -> Any:
value: Any = mapping
for key in keys:
if not isinstance(value, dict) or key not in value:
raise KeyError(".".join(keys))
value = value[key]
return value
def _write_json_atomic(path: Path, payload: dict[str, Any]) -> None:
temporary = path.with_name(f".{path.name}.tmp")
with temporary.open("w", encoding="utf-8") as handle:
json.dump(payload, handle, indent=2, sort_keys=True)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
def _validate_checkpoint_config(
checkpoint_config: dict[str, Any],
run_config: dict[str, Any],
) -> None:
critical_paths = (
("training", "distributed", "num_gpus"),
("training", "distributed", "sp_size"),
("training", "data", "data_path"),
("training", "data", "dataloader_type"),
("training", "data", "streaming_manifest_path"),
("training", "data", "streaming_read_batch_size"),
("training", "data", "streaming_shuffle_row_groups"),
("training", "data", "dataloader_num_workers"),
("training", "data", "train_batch_size"),
("training", "data", "seed"),
("training", "loop", "gradient_accumulation_steps"),
)
mismatches = []
for path in critical_paths:
checkpoint_value = _nested(checkpoint_config, *path)
run_value = _nested(run_config, *path)
if checkpoint_value != run_value:
mismatches.append((".".join(path), checkpoint_value, run_value))
if mismatches:
formatted = "; ".join(
f"{path}: checkpoint={old!r}, config={new!r}"
for path, old, new in mismatches
)
raise ValueError(f"Run config does not match checkpoint metadata: {formatted}")
def migrate(args: argparse.Namespace) -> dict[str, Any]:
config_path = Path(args.config).expanduser().resolve()
checkpoint_dir = Path(args.checkpoint).expanduser().resolve()
output_dir = Path(args.output_dir).expanduser().resolve()
if output_dir == checkpoint_dir or checkpoint_dir in output_dir.parents:
raise ValueError("Migration output must be outside the legacy checkpoint")
if not (checkpoint_dir / "dcp").is_dir():
raise FileNotFoundError(f"Missing DCP directory: {checkpoint_dir / 'dcp'}")
metadata_path = checkpoint_dir / "metadata.json"
with metadata_path.open(encoding="utf-8") as handle:
metadata = json.load(handle)
step = int(metadata["step"])
if step != int(args.expected_step):
raise ValueError(f"Checkpoint step mismatch: expected {args.expected_step}, got {step}")
with config_path.open(encoding="utf-8") as handle:
run_config = yaml.safe_load(handle)
if not isinstance(run_config, dict):
raise ValueError("Run config must be a YAML mapping")
checkpoint_config = metadata.get("config")
if not isinstance(checkpoint_config, dict):
raise ValueError("Legacy checkpoint metadata has no embedded run config")
_validate_checkpoint_config(checkpoint_config, run_config)
distributed = _nested(run_config, "training", "distributed")
data = _nested(run_config, "training", "data")
loop = _nested(run_config, "training", "loop")
if str(data["dataloader_type"]).strip().lower() != "streaming":
raise ValueError("This migration only supports the streaming dataloader")
if int(data["dataloader_num_workers"]) != 0:
raise ValueError("Legacy migration currently requires dataloader_num_workers=0")
world_size = int(distributed["num_gpus"])
if world_size != int(args.expected_world_size):
raise ValueError(
f"World-size mismatch: expected {args.expected_world_size}, config has {world_size}"
)
sp_world_size = int(distributed["sp_size"])
batch_size = int(data["train_batch_size"])
grad_accum = int(loop["gradient_accumulation_steps"])
consumed_batches = int(args.consumed_batches_per_rank)
expected_batches = step * grad_accum
if consumed_batches != expected_batches:
raise ValueError(
f"Consumed batch count mismatch: explicitly supplied {consumed_batches}, "
f"but step*gradient_accumulation_steps is {expected_batches}"
)
yielded_samples = consumed_batches * batch_size
manifest_path = Path(data["streaming_manifest_path"]).expanduser().resolve()
with manifest_path.open(encoding="utf-8") as handle:
manifest = json.load(handle)
dataset_root = os.path.realpath(os.path.expanduser(str(data["data_path"])))
if manifest.get("dataset_root") != dataset_root:
raise ValueError("Streaming manifest belongs to a different dataset root")
output_dir.mkdir(parents=True, exist_ok=True)
existing = list(output_dir.glob("dataloader_state_rank*.pt"))
if existing and not args.overwrite:
raise FileExistsError(
f"Migration output already contains {len(existing)} sidecars; use --overwrite"
)
sidecars = []
for rank in range(world_size):
dataset_state = reconstruct_streaming_dataset_state(
manifest,
global_rank=rank,
world_size=world_size,
sp_world_size=sp_world_size,
num_workers=0,
batch_size=batch_size,
read_batch_size=int(data["streaming_read_batch_size"]),
seed=int(data["seed"]),
shuffle_row_groups=bool(data["streaming_shuffle_row_groups"]),
yielded_samples=yielded_samples,
)
payload = {
"version": _DATALOADER_STATE_VERSION,
"rank": rank,
"world_size": world_size,
"step": step,
"state_kind": "dataset",
"state": dataset_state,
}
path = output_dir / f"dataloader_state_rank{rank}.pt"
_atomic_torch_save(payload, path)
loaded = torch.load(path, map_location="cpu", weights_only=False)
if loaded != payload:
raise RuntimeError(f"Sidecar round-trip mismatch: {path}")
sidecars.append(
{
"rank": rank,
"path": path.name,
"sha256": _sha256(path),
"row_group_position": dataset_state["row_group_position"],
"row_offset": dataset_state["row_offset"],
"yielded_samples": dataset_state["yielded_samples"],
}
)
receipt = {
"schema": "fastvideo-legacy-streaming-dataloader-migration/v1",
"created_at_utc": datetime.now(timezone.utc).isoformat(),
"source_checkpoint": str(checkpoint_dir),
"source_checkpoint_metadata_sha256": _sha256(metadata_path),
"source_config": str(config_path),
"source_config_sha256": _sha256(config_path),
"manifest": str(manifest_path),
"manifest_fingerprint": manifest["fingerprint"],
"step": step,
"world_size": world_size,
"sp_world_size": sp_world_size,
"batch_size": batch_size,
"gradient_accumulation_steps": grad_accum,
"consumed_batches_per_rank": consumed_batches,
"yielded_samples_per_rank": yielded_samples,
"sidecars": sidecars,
}
_write_json_atomic(output_dir / "migration_receipt.json", receipt)
return receipt
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--config", required=True)
parser.add_argument("--checkpoint", required=True)
parser.add_argument("--output-dir", required=True)
parser.add_argument("--expected-step", required=True, type=int)
parser.add_argument("--expected-world-size", required=True, type=int)
parser.add_argument("--consumed-batches-per-rank", required=True, type=int)
parser.add_argument("--overwrite", action="store_true")
args = parser.parse_args()
receipt = migrate(args)
print(json.dumps(receipt, indent=2, sort_keys=True))
if __name__ == "__main__":
main()
@@ -0,0 +1,153 @@
# SPDX-License-Identifier: Apache-2.0
"""Validate migrated T2V streaming sidecars under the real process topology."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import pyarrow.parquet as pq
import torch
import torch.distributed as dist
import yaml
from fastvideo.dataset.dataloader.schema import pyarrow_schema_t2v
from fastvideo.dataset.parquet_dataset_streaming_style import (
build_parquet_streaming_style_dataloader,
)
from fastvideo.distributed import (
cleanup_dist_env_and_memory,
maybe_init_distributed_environment_and_model_parallel,
)
from fastvideo.train.utils.checkpoint import CheckpointConfig, CheckpointManager
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _next_manifest_id(dataset: Any, state: dict[str, Any]) -> str:
shard = dataset._worker_shard(0)
position = int(state["row_group_position"])
offset = int(state["row_offset"])
item = shard[position]
if offset == int(item["rows"]):
position += 1
offset = 0
item = shard[position]
parquet_file = pq.ParquetFile(os.path.join(dataset.path, item["file"]))
ids = parquet_file.read_row_group(int(item["row_group"]), columns=["id"]).column("id")
return str(ids[offset].as_py())
def _write_json_atomic(path: Path, payload: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.tmp")
with temporary.open("w", encoding="utf-8") as handle:
json.dump(payload, handle, indent=2, sort_keys=True)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
def validate(args: argparse.Namespace) -> None:
maybe_init_distributed_environment_and_model_parallel(tp_size=1, sp_size=1)
try:
rank = dist.get_rank()
world_size = dist.get_world_size()
config_path = Path(args.config).expanduser().resolve()
checkpoint_dir = Path(args.checkpoint).expanduser().resolve()
sidecar_dir = Path(args.sidecar_dir).expanduser().resolve()
with config_path.open(encoding="utf-8") as handle:
config = yaml.safe_load(handle)
training = config["training"]
distributed = training["distributed"]
data = training["data"]
if world_size != int(distributed["num_gpus"]):
raise ValueError(
f"Runtime world_size={world_size} does not match config num_gpus={distributed['num_gpus']}"
)
if int(distributed["sp_size"]) != 1:
raise ValueError("This validator currently requires sp_size=1")
dataset, dataloader = build_parquet_streaming_style_dataloader(
path=str(data["data_path"]),
batch_size=int(data["train_batch_size"]),
num_data_workers=int(data["dataloader_num_workers"]),
parquet_schema=pyarrow_schema_t2v,
manifest_path=str(data["streaming_manifest_path"]),
cfg_rate=float(data["training_cfg_rate"]),
text_padding_length=512,
seed=int(data["seed"]),
read_batch_size=int(data["streaming_read_batch_size"]),
shuffle_row_groups=bool(data["streaming_shuffle_row_groups"]),
)
sidecar_path = sidecar_dir / f"dataloader_state_rank{rank}.pt"
payload = torch.load(sidecar_path, map_location="cpu", weights_only=False)
expected_id = _next_manifest_id(dataset, payload["state"])
os.environ["FASTVIDEO_DATALOADER_STATE_DIR"] = str(sidecar_dir)
manager = CheckpointManager(
method=object(),
dataloader=dataloader,
output_dir=str(checkpoint_dir.parent),
config=CheckpointConfig(save_steps=0, keep_last=0),
)
manager._load_dataloader_snapshot(checkpoint_dir, int(args.expected_step))
actual_id = str(next(iter(dataloader))["info_list"][0]["id"])
if actual_id != expected_id:
raise AssertionError(
f"Rank {rank} resumed at {actual_id!r}; expected {expected_id!r}"
)
result = {
"rank": rank,
"sidecar": sidecar_path.name,
"sidecar_sha256": _sha256(sidecar_path),
"next_id": actual_id,
"row_group_position": payload["state"]["row_group_position"],
"row_offset": payload["state"]["row_offset"],
}
gathered: list[dict[str, Any] | None] = [None] * world_size
dist.all_gather_object(gathered, result)
if rank == 0:
receipt = {
"schema": "fastvideo-streaming-dataloader-sidecar-validation/v1",
"created_at_utc": datetime.now(timezone.utc).isoformat(),
"checkpoint": str(checkpoint_dir),
"checkpoint_metadata_sha256": _sha256(checkpoint_dir / "metadata.json"),
"config": str(config_path),
"config_sha256": _sha256(config_path),
"sidecar_dir": str(sidecar_dir),
"step": int(args.expected_step),
"world_size": world_size,
"results": gathered,
}
_write_json_atomic(Path(args.output).expanduser().resolve(), receipt)
print(json.dumps(receipt, indent=2, sort_keys=True))
dist.barrier()
finally:
cleanup_dist_env_and_memory()
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--config", required=True)
parser.add_argument("--checkpoint", required=True)
parser.add_argument("--sidecar-dir", required=True)
parser.add_argument("--expected-step", required=True, type=int)
parser.add_argument("--output", required=True)
validate(parser.parse_args())
if __name__ == "__main__":
main()