fix(checkpoint): survive an interrupted save instead of hanging all ranks (#3261)
* fix(checkpoint): survive an interrupted save instead of hanging all ranks A save cut short by a wall-clock limit, preemption, or OOM leaves a partial epoch_<E>_step_<N> directory behind. Because checkpoint directory names are derived from the step, the resumed run recomputed step N and collided with that leftover. save_checkpoint raised FileExistsError on rank 0 only, so rank 0 died while every other rank blocked in the next collective — an indefinite NCCL hang that idled 128 GPUs until the cluster reaper killed the job, repeating in every subsequent resume window for zero net progress. Mark each checkpoint directory as in progress while it is being written and clear the mark only at publication, so an interrupted save stays observable on disk. Replace the rank-0 guard with a cleanup whose outcome is reduced across ranks, so a failure aborts the whole job rather than stranding it. Fixes #3254 Changelog: - Add mark_/clear_/is_checkpoint_incomplete to the checkpoint component and write the marker for the whole save window. - Replace the rank-0 FileExistsError with BaseRecipe._reserve_checkpoint_dir: rank 0 removes any leftover directory, and the result is all-reduced so every rank raises together instead of hanging on a rank-0 death. - Add BaseRecipe._publish_checkpoint as the single point where a checkpoint becomes resumable (clear marker, then advance LATEST), covering both the sync path and deferred async publication. - Skip marked directories in find_latest_checkpoint's step-scan fallback, so a run whose first save was interrupted cannot resume from a partial checkpoint before any LATEST pointer exists. - Exclude marked directories from the max_recent_checkpoints retention window so a leftover neither evicts a good checkpoint nor survives as an orphan. - Route the four speculative recipes (DFlash, EAGLE-1, EAGLE-3, DSpark), which each carried a copy of the same guard, through the shared methods. - Document the behavior in the checkpointing guide, including that replacement also applies to a complete checkpoint when resuming from an older one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Yuhe Zhang <yuhez@nvidia.com> * fix(checkpoint): close the review gaps in interrupted-save handling Addresses three review findings on the interrupted-save fix. Pointer targets bypassed the marker filter. find_latest_checkpoint and resolve_restore_from_to_checkpoint_dir returned a LATEST or named-pointer target before the incomplete check ran, so re-saving a step that LATEST already pointed at and then being interrupted resumed from the partial directory. Both now skip an incomplete pointer target: LATEST falls back to the most recent complete checkpoint, and any other pointer fails loudly instead of restoring partial state. Rank-0 bookkeeping could still strand peers. os.makedirs, the marker write, and the marker clear ran on rank 0 after the reduction, so an I/O error there killed rank 0 while its peers entered the next barrier. Directory creation and the marker write now sit inside the same guarded block as the removal and share one reduction; the marker clear is non-fatal and leaves LATEST where it is, so the checkpoint stays flagged incomplete and resume falls back. The lifecycle silently did nothing for msc:// roots. Path, os.path.exists, shutil.rmtree, and os.makedirs treated a remote path as a local one, writing the marker into a bogus local msc:/... directory while the real checkpoint went unmarked. Remote roots are now detected and skipped explicitly, with a warning and a documented caveat, matching the existing local-only scope of checkpoint discovery and retention. Changelog: - Skip incomplete pointer targets in find_latest_checkpoint and resolve_restore_from_to_checkpoint_dir, falling back to the most recent complete checkpoint for LATEST. - Cover directory creation and marker creation by the same cross-rank reduction as the leftover removal, extracted as BaseRecipe._any_rank_failed. - Make the marker clear in _publish_checkpoint non-fatal and leave LATEST unchanged when it fails. - Move is_cloud_path into the checkpoint utils module, make the marker helpers no-ops for msc:// paths, and skip the reserve step for remote checkpoint roots with a warning. - Document the remote-storage limitation in the checkpointing guide. - Add seven tests: interrupted pointer target with and without a fallback, explicit pointer restore, makedirs and marker-write failures aborting every rank, non-fatal marker clear, and the remote-path no-op. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Yuhe Zhang <yuhez@nvidia.com> * test: stop leaking a process group across the unit test session tests/unit_tests/recipes/test_vlm_kd_tp_cp_correctness.py initialized a single-rank gloo process group in a module-scoped fixture and never destroyed it, so torch.distributed.is_initialized() stayed true for the rest of the pytest session. That silently corrupts later tests. get_world_size_safe() returns the live group's size instead of reading WORLD_SIZE from the environment, so test_save_time_warns_for_large_inline_consolidation asserted world_size=256 and observed world_size=1. Tests that skip when a group already exists, such as the DDP no_sync and DSpark max-steps reducer tests, stop running at all. Whether it bites depends on collection order. Running the checkpoint suite before the recipes suite hides it, which is why CI stayed green while `pytest tests/unit_tests/recipes tests/unit_tests/checkpoint` failed. Changelog: - Convert trivial_pg to a yield fixture that destroys the group it created in a finally block, leaving a pre-existing group alone. - Add a session-scoped autouse guard in tests/unit_tests/conftest.py that fails the session when a process group outlives it, so the next leak is reported directly instead of surfacing as an unrelated assertion. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Yuhe Zhang <yuhez@nvidia.com> * fix(checkpoint): repair remote saves and pointer handling for interrupted saves Addresses three review findings on e27fa9ed. msc:// saves broke. _reserve_checkpoint_dir returned early for remote roots without creating the directory, but the rest of the save path writes rank-0 files under it with plain open and torch.save. losses.json raised FileNotFoundError on rank 0 before the first barrier, so the peers blocked -- the exact hang this branch set out to remove. Directory creation now runs for remote roots too; only the leftover cleanup and the marker, which are local filesystem operations, are skipped. Incomplete named pointers still restored partial state. Skipping an incomplete LOWEST_VAL target fell through to <checkpoint_dir>/LOWEST_VAL, which is the pointer itself and resolves straight back to the marked checkpoint. A non-LATEST pointer at an incomplete checkpoint now raises; LATEST keeps its fallback to the most recent complete checkpoint. A failed marker clear could still publish LOWEST_VAL. _publish_checkpoint returned silently while its callers went on to _update_best_symlink and _prune_old_checkpoints, leaving LOWEST_VAL aimed at a directory whose marker was still present. Publication now reports success and gates every dependent pointer and retention update, in the sync path and in deferred async publication. Changelog: - Keep directory creation in _reserve_checkpoint_dir for msc:// roots and skip only the local-filesystem cleanup and marker. - Add save_losses to the checkpoint component, mirroring save_config's storage handling, and route losses.json through it from all five recipes; failures warn instead of raising on rank 0. - Raise from resolve_restore_from_to_checkpoint_dir when a named pointer other than LATEST targets an incomplete checkpoint. - Return publication success from _publish_checkpoint and gate the best-pointer and retention updates on it in base_recipe and the four speculative recipes. - Add tests over the complete remote save path, the loss-metadata helper, the named-pointer rejection, and both sync and async publication gating. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Yuhe Zhang <yuhez@nvidia.com> * fix(checkpoint): reject remote checkpoint roots and unpin interrupted checkpoints Addresses the review of 6b699414 by fixing the cause rather than the three symptoms: the branch had been accumulating msc:// special cases around a save path that cannot write to object storage at all. msc:// training checkpoints have never worked. Only the DCP model and optimizer shards and config.yaml are MSC-aware; RNG, dataloader, and recipe metadata go through torch.save, and LATEST and LOWEST_VAL are published with os.symlink. Verified against origin/main: save_on_dp_ranks raises "Parent directory msc://.../rng does not exist" and os.makedirs leaves a local msc: shadow tree, both before this branch existed. Rather than keep patching, checkpoint_dir now rejects remote roots at config construction. Every rank builds the config identically, so the failure is collective and lands at startup instead of partway through a save. This deletes the msc:// branches this branch had added to _reserve_checkpoint_dir and to the marker helpers. Retention pinned unusable checkpoints. _prune_old_checkpoints excluded the whole pointer-protected set from deletion, including checkpoints carrying the in-progress marker, so a LOWEST_VAL pointer at a checkpoint that was overwritten and then interrupted kept it forever and broke the documented retention window. Incomplete checkpoints no longer receive pointer protection and their dangling pointer is dropped with them. save_losses could still deadlock. _ensure_msc_available raises ImportError, which the handler did not catch, so rank 0 could exit before a barrier. The handler now covers it too, in addition to the config-level rejection. Changelog: - Reject msc:// checkpoint.checkpoint_dir in CheckpointingConfig, replacing the two narrower msc:// rejections whose guidance no longer holds. - Drop pointer protection for checkpoints left by an interrupted save so retention deletes them and removes the dangling pointer. - Catch ImportError in save_losses. - Remove the msc:// special casing from _reserve_checkpoint_dir, the marker helpers, and checkpoint_lifecycle_is_supported, now unreachable. - Document that checkpoint_dir must be a local or shared filesystem path, and that interrupted checkpoints are never pointer-protected. - Extend tests: config rejection across retention and consolidation settings, retention with pointers at complete and incomplete targets, save_losses without multistorageclient, and a sync/async by restore-source lifecycle matrix over an interrupted save. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Yuhe Zhang <yuhez@nvidia.com> * fix(checkpoint): keep interrupted checkpoints out of best-metric and pointer state Two follow-ups from the review of 4d40fd8e. An incomplete LOWEST_VAL target poisoned best-metric tracking. _update_best_symlink runs before pruning and seeds _best_val_loss from the pointer target's losses.json, including a target left behind by an interrupted save. That metric belongs to a checkpoint the run can never restore, so it held the baseline below every later checkpoint: with 0.1 recorded on the marked target, a 0.2 checkpoint did not replace it, pruning then removed the target and its pointer, and a later 0.15 checkpoint still could not recreate LOWEST_VAL because the cached value survived. The baseline now skips marked targets. Custom pointers were left dangling. Dropping pointer protection for marked checkpoints lets retention delete a directory that any top-level pointer may target, but cleanup only handled LATEST and LOWEST_VAL, so a user's PINNED pointer survived its deleted target. Cleanup now covers every pointer the protection scan already discovers, matching what the retention documentation claims. Staleness is now tested by target non-existence rather than "is not a directory". Two existing tests caught that the stricter check would delete pointers aimed at a file inside a live checkpoint, which are legitimate. Changelog: - Skip incomplete LOWEST_VAL targets in _initialize_best_val_loss_from_pointer. - Add find_checkpoint_pointers to the checkpoint utils, returning every top-level pointer and its target, and build find_pointer_protected_checkpoints on it. - Replace _remove_stale_checkpoint_pointer with _remove_stale_checkpoint_pointers, which removes any pointer whose target no longer exists and leaves pointers with live targets alone. - Document that dangling pointers are cleaned up and live ones are never touched. - Add regression tests for the multi-save best-metric sequence and for custom pointer cleanup alongside a preserved pointer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Yuhe Zhang <yuhez@nvidia.com> * fix(checkpoint): narrow the interrupted-save fix to marking and ignoring The review of b671b442 was right that the three remaining blockers were not independent: each one only existed because this branch had grown past "mark a checkpoint while it is being written and ignore marked checkpoints". Narrowing the semantics removes all three by construction rather than by another targeted patch, and shrinks the change. Only unpublished checkpoints are replaced. A directory carrying the marker was never published and cannot be restored, so reclaiming it loses nothing. A directory without the marker holds a published checkpoint and is no longer overwritten; the save aborts on every rank instead. An async checkpoint whose files finished but was never published still carries the marker, so the case the issue reported stays covered. Because a published checkpoint is never rewritten, its recorded metric can no longer go stale behind LOWEST_VAL, which was the first blocker, and no pointer can come to target a marked checkpoint, which is why the pointer-protection and custom-pointer cleanup added earlier are both reverted -- including the dictionary that collapsed the symlink and .txt forms, the second blocker. Every rank-0 filesystem mutation in the lifecycle now runs through one helper, _run_rank_0_checkpoint_step, which reduces the outcome so either every rank continues or every rank raises. That covers the pointer unlink of the third blocker, and also the pre-existing rank-0 torch.save of tracked state, which could strand peers the same way. Publication no longer reports success through a return value, because failures are collective instead of silently degrading. Changelog: - Replace only marked checkpoint directories; raise FileExistsError on every rank when the target holds a published checkpoint. - Add BaseRecipe._run_rank_0_checkpoint_step as the single guard for rank-0 checkpoint filesystem work, and route reservation, publication, best-pointer updates, retention, and the tracked-state torch.save through it. - Revert find_checkpoint_pointers, the pointer-protection exclusion, and the all-pointer stale cleanup; restore the original two-pointer cleanup, now covered by the guard. - Keep the retention window computed over complete checkpoints only, so a marked leftover neither consumes a slot nor survives as an orphan. - Restructure the tests as a lifecycle state machine (published, interrupted, ignored, replaced, published) plus failure injection at each rank-0 filesystem operation, including the pointer unlink, and drop the nine tests that asserted the reverted behavior. - Document that only marked directories are replaced and that a published checkpoint is never overwritten. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Yuhe Zhang <yuhez@nvidia.com> * fix(checkpoint): make reservation failure-atomic and guard every callback failure Two guard failures from the review of 906d5babc, both in code the previous rework introduced. Reservation was not failure-atomic. It created the final directory and then wrote the marker, so a marker write that failed -- or a process that stopped between the two calls -- left the directory unmarked. Since the absence of the marker is exactly what makes a directory look published, find_latest_checkpoint then resumed from an empty directory and the next save refused to replace it. Reproduced both outcomes. Reservation now builds the directory complete with its marker under a `.reserving-<uuid>` staging name and renames it into place, which is atomic within a filesystem, so the final path is never observable without its marker. A reservation that dies partway leaves only the staging directory, which neither checkpoint listing matches because the name does not end in step_<N>, and which the next reservation of that step clears. The rank-0 guard caught only OSError. Its callbacks now reach state_dict(), torch.save, pointer publication, and retention, which raise RuntimeError and pickling errors too. A RuntimeError injected into torch.save escaped before the reduction, so rank 0 died while its peers waited -- the original hang. The guard now catches Exception, logs it on rank 0, reports it through the reduction, and chains the original as the cause of the collective RuntimeError. Auditing the same invariant across the recipes found the equivalent unguarded rank-0 metadata write in all four speculative recipes, which is now routed through the guard as well. Changelog: - Reserve checkpoint directories through a marked staging directory renamed into place, and clear stale staging directories for the step being reserved. - Catch Exception rather than OSError in _run_rank_0_checkpoint_step, preserving the original exception as the cause. - Route the rank-0 recipe-metadata write in the DFlash, EAGLE-1, EAGLE-3, and DSpark recipes through the guard. - Add regression tests for a failed reservation leaving nothing resumable, for a stale staging directory being ignored and cleaned up, and for a non-OSError rank-0 failure aborting every rank with its cause preserved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Yuhe Zhang <yuhez@nvidia.com> * fix(checkpoint): make publication failure-atomic Signed-off-by: Yuhe Zhang <yuhez@nvidia.com> * docs(checkpoint): clarify async publication lifecycle Signed-off-by: Yuhe Zhang <yuhez@nvidia.com> * fix(checkpoint): preserve MSC DCP support Signed-off-by: Yuhe Zhang <yuhez@nvidia.com> * refactor(checkpoint): extract publication lifecycle Signed-off-by: Yuhe Zhang <yuhez@nvidia.com> * docs(checkpoint): apply review feedback Signed-off-by: Yuhe Zhang <yuhez@nvidia.com> * test(recipe): update checkpointer finalization fixture Signed-off-by: Yuhe Zhang <yuhez@nvidia.com> --------- Signed-off-by: Yuhe Zhang <yuhez@nvidia.com> Signed-off-by: Alexandros Koumparoulis <akoumparouli@nvidia.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Alexandros Koumparoulis <153118171+akoumpa@users.noreply.github.com> Co-authored-by: Alexandros Koumparoulis <akoumparouli@nvidia.com>
Y
Yuhe Zhang committed
0f526526f42180b9bcbae8f8178704e77ef87de2
Parent: ea16f15
Committed by GitHub <noreply@github.com>
on 8/4/2026, 11:46:46 AM