-
Notifications
You must be signed in to change notification settings - Fork 2
Write the emergency checkpoint when dataloader workers are in use #203
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,6 +17,27 @@ | |
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| # DCP writes this file last, once every shard is durable, so its presence is | ||
| # the authoritative signal that a checkpoint directory is loadable. | ||
| _DCP_METADATA_FILE = ".metadata" | ||
|
|
||
|
|
||
| def _dcp_durable(ckpt_dir: Path) -> bool: | ||
| """Whether ``ckpt_dir`` holds a complete set of DCP shards. | ||
|
|
||
| Accepts both layouts: a flat directory, and the per-stage ``pp{k}/`` | ||
| subdirectories written under pipeline parallelism. | ||
| """ | ||
| if (ckpt_dir / _DCP_METADATA_FILE).exists(): | ||
| return True | ||
| if not ckpt_dir.is_dir(): | ||
| return False | ||
| return any( | ||
| (d / _DCP_METADATA_FILE).exists() | ||
| for d in ckpt_dir.iterdir() | ||
| if d.is_dir() and d.name.startswith("pp") | ||
| ) | ||
|
|
||
|
|
||
| @dataclass | ||
| class SLURMInfo: | ||
|
|
@@ -77,13 +98,20 @@ def resolve_resume_path(checkpoint_dir: str) -> Path | None: | |
|
|
||
| Checks: | ||
| 1. ``{checkpoint_dir}/latest`` symlink | ||
| 2. Most recent ``step_N`` directory by step number | ||
| 2. Most recent ``step_N`` directory *whose DCP shards are durable* | ||
|
|
||
| ``CheckpointManager`` only ever points ``latest`` at a durable checkpoint, | ||
| so (1) needs no further test. The ``step_N`` fallback has no such guarantee: | ||
| an interrupted save leaves a directory that exists but holds no ``.metadata``, | ||
| and resuming into it fails in ``dcp.load`` with "metadata is None". Skipping | ||
| incomplete directories turns an unrecoverable run into one that resumes from | ||
| the last durable checkpoint, whatever left the partial directory behind. | ||
|
|
||
| Args: | ||
| checkpoint_dir: Base checkpoint directory. | ||
|
|
||
| Returns: | ||
| Path to the latest checkpoint, or None if none found. | ||
| Path to the latest usable checkpoint, or None if none found. | ||
| """ | ||
| base = Path(checkpoint_dir) | ||
| if not base.exists(): | ||
|
|
@@ -97,20 +125,25 @@ def resolve_resume_path(checkpoint_dir: str) -> Path | None: | |
| logger.info(f"Auto-resume: found latest checkpoint at {resolved}") | ||
| return resolved | ||
|
|
||
| # Fall back to most recent step_N directory | ||
| # Fall back to the newest durable step_N directory, newest first. | ||
| step_dirs = sorted( | ||
| ( | ||
| d | ||
| for d in base.iterdir() | ||
| if d.is_dir() and d.name.startswith("step_") and d.name.split("_")[1].isdigit() | ||
| ), | ||
| key=lambda d: int(d.name.split("_")[1]), | ||
| reverse=True, | ||
| ) | ||
|
|
||
| if step_dirs: | ||
| path = step_dirs[-1] | ||
| logger.info(f"Auto-resume: found checkpoint at {path}") | ||
| return path | ||
| for path in step_dirs: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Possible hang: Every rank now stats |
||
| if _dcp_durable(path): | ||
| logger.info(f"Auto-resume: found checkpoint at {path}") | ||
| return path | ||
| logger.warning( | ||
| f"Auto-resume: skipping {path} — no DCP .metadata, so the save that " | ||
| f"produced it did not complete" | ||
| ) | ||
|
|
||
| return None | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Before this PR the function always returned a path when step_N directories existed. Now, if none of them looks durable, it returns nothing, and the entry path reads that as "no checkpoint to resume" and trains from scratch with no error. |
||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,6 +21,33 @@ | |
| _SHUTDOWN_SIGNALS = (signal.SIGTERM, signal.SIGUSR1) | ||
|
|
||
|
|
||
| def ignore_shutdown_signals_in_worker(worker_id: int) -> None: | ||
| """``worker_init_fn`` that makes a DataLoader worker ignore shutdown signals. | ||
|
|
||
| SLURM preemption and ``torchrun`` teardown signal the whole process group, | ||
| not a single PID, so SIGTERM reaches every DataLoader worker as well as the | ||
| rank's main process. Workers install no handler, take the default action and | ||
| die at once. The main process — which handles the signal cooperatively, by | ||
| setting a flag and finishing its step — then fails fetching its next | ||
| micro-batch, and that failure escapes before the loop reaches its | ||
| ``should_shutdown()`` check, so no emergency checkpoint is written. | ||
|
|
||
| Ignoring the shutdown signals in the worker keeps it serving long enough for | ||
| the step to finish, which puts the emergency save back on the loop's normal | ||
| path rather than on an exception path. | ||
|
|
||
| The cost: ``multiprocessing.Process.terminate()`` is itself SIGTERM, so a | ||
| shielded worker cannot be reaped that way and the interpreter's exit hook | ||
| would block joining it. Workers must therefore be shut down explicitly | ||
| through the loader's sentinel path — see ``BatchStream.close``. | ||
|
|
||
| Args: | ||
| worker_id: Worker index, supplied by ``DataLoader``. Unused. | ||
| """ | ||
| for sig in _SHUTDOWN_SIGNALS: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. in close(), w.kill() any worker still alive after _shutdown_workers(). |
||
| signal.signal(sig, signal.SIG_IGN) | ||
|
|
||
|
|
||
| class ShutdownHandler: | ||
| """Cooperative shutdown handler for long-running training jobs. | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,6 +27,7 @@ | |
| from kempnerforge.data.sampler import DistributedSampler, MixtureSampler | ||
| from kempnerforge.distributed.utils import get_dp_info | ||
| from kempnerforge.metrics.logger import get_logger | ||
| from kempnerforge.resilience.signal_handler import ignore_shutdown_signals_in_worker | ||
| from kempnerforge.training.eval import should_build_eval_dataloader | ||
| from kempnerforge.training.runtime import RuntimeContext | ||
|
|
||
|
|
@@ -286,6 +287,8 @@ def _build_hf_pipeline(config: JobConfig, dp_rank: int, dp_size: int) -> DataPip | |
| num_workers=config.data.num_workers, | ||
| pin_memory=config.data.pin_memory, | ||
| prefetch_factor=(config.data.prefetch_factor if config.data.num_workers > 0 else None), | ||
| # See StatefulDataLoader: workers survive a group-delivered SIGTERM. | ||
| worker_init_fn=ignore_shutdown_signals_in_worker, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Better to pass the
|
||
| ) | ||
| logger.info( | ||
| f"Dataset: streaming from {config.data.hf_dataset_name} " | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
anycalls a PP checkpoint durable when only some stages flushed, which is whatan interrupted PP save looks like (all ranks mkdir their own pp{k}/ up front).
_resolve_dcp_load_dir then re-tests per rank, so stage 0 resumes at step_20 and
stage 1 at step_10, silently.