Source code for kempnerforge.resilience.elastic

"""Elastic training and SLURM integration helpers.

Provides utilities for training jobs that may be preempted, requeued,
or restarted with a different number of nodes:

- SLURM job info detection
- Requeue detection
- Auto-resume path resolution
"""

from __future__ import annotations

import logging
import os
from dataclasses import dataclass
from pathlib import Path

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.

    Under pipeline parallelism *every* stage must be durable. Each stage saves
    through its own process group, so an interrupted save can leave the earlier
    stages complete and the later ones empty; resuming into that mix fails in
    ``dcp.load`` on the stages that have no ``.metadata``, or resumes them from
    different steps. An empty stage list is not durable either -- ``all()`` is
    vacuously true on it, and a directory with no stage at all holds nothing.
    """
    if (ckpt_dir / _DCP_METADATA_FILE).exists():
        return True
    if not ckpt_dir.is_dir():
        return False
    stages = [d for d in ckpt_dir.iterdir() if d.is_dir() and d.name.startswith("pp")]
    return bool(stages) and all((d / _DCP_METADATA_FILE).exists() for d in stages)


[docs] @dataclass class SLURMInfo: """Information about the current SLURM job.""" job_id: str job_name: str node_list: str num_nodes: int ntasks_per_node: int restart_count: int partition: str array_task_id: str | None # None if not an array job @property def is_requeued(self) -> bool: """Whether this job has been requeued (restart_count > 0).""" return self.restart_count > 0
[docs] def get_slurm_info() -> SLURMInfo | None: """Read SLURM job information from environment variables. Returns: SLURMInfo if running under SLURM, None otherwise. """ job_id = os.environ.get("SLURM_JOB_ID") if job_id is None: return None return SLURMInfo( job_id=job_id, job_name=os.environ.get("SLURM_JOB_NAME", ""), node_list=os.environ.get("SLURM_JOB_NODELIST", ""), num_nodes=int(os.environ.get("SLURM_NNODES", "1")), ntasks_per_node=int(os.environ.get("SLURM_NTASKS_PER_NODE", "1")), restart_count=int(os.environ.get("SLURM_RESTART_COUNT", "0")), partition=os.environ.get("SLURM_JOB_PARTITION", ""), array_task_id=os.environ.get("SLURM_ARRAY_TASK_ID"), )
[docs] def is_slurm_job() -> bool: """Check if we are running under SLURM.""" return "SLURM_JOB_ID" in os.environ
[docs] def is_slurm_requeue() -> bool: """Check if this is a requeued SLURM job. Uses ``SLURM_RESTART_COUNT`` (set by SLURM on requeue). """ return int(os.environ.get("SLURM_RESTART_COUNT", "0")) > 0
[docs] def resolve_resume_path(checkpoint_dir: str) -> Path | None: """Find the latest checkpoint for auto-resume. Checks: 1. ``{checkpoint_dir}/latest`` symlink 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 usable checkpoint, or None if none found. """ base = Path(checkpoint_dir) if not base.exists(): return None # Check "latest" symlink first latest = base / "latest" if latest.exists(): resolved = latest.resolve() if resolved.exists(): logger.info(f"Auto-resume: found latest checkpoint at {resolved}") return resolved # 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, ) for path in step_dirs: 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" ) if step_dirs: # Behaviour is unchanged -- the caller starts from step 0 -- but this is # the one path where a run that plainly meant to resume does not. Before # the durability test it failed inside dcp.load instead, which was at # least loud, so say so at a level nobody filters out. logger.error( f"Auto-resume: {len(step_dirs)} checkpoint directories under " f"{base} and none has durable DCP state; starting from step 0. " f"Every saved step would have to be re-trained." ) return None
[docs] def log_job_info() -> None: """Log SLURM job information (if running under SLURM).""" info = get_slurm_info() if info is None: logger.info("Not running under SLURM") return logger.info( f"SLURM job: id={info.job_id}, name={info.job_name}, " f"nodes={info.num_nodes}, tasks/node={info.ntasks_per_node}, " f"partition={info.partition}, restart_count={info.restart_count}" ) if info.is_requeued: logger.info(f"Job was requeued (restart #{info.restart_count}) — will auto-resume")