Fix checkpoint rotation after successful saves - #2989
Merged
Conversation
Contributor
There was a problem hiding this comment.
Pull request overview
This PR corrects checkpoint retention behavior by saving checkpoints first and rotating only after a successful save, while protecting the newly-written checkpoint from deletion. It also improves temp-checkpoint handling, aligns checkpoints_total_limit=0 with documented “unlimited” semantics for completed checkpoints, and makes Hub uploads use the exact checkpoint path scheduled for upload.
Changes:
- Reorders standard and rolling checkpoint flows to: temp cleanup → save → post-save rotation (with optional protected checkpoint).
- Updates checkpoint parsing to correctly recognize
*-tmpfor both standard and rolling checkpoints (using the last-segment). - Passes an explicit checkpoint path to Hub upload and adds tests covering rotation/retention edge cases and distributed tempdir synchronization.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| tests/test_trainer.py | Adds regression tests for temp cleanup, post-save rotation behavior, protected checkpoints, Hub upload path usage, rolling checkpoint behavior, and distributed tempdir barriers. |
| simpletuner/helpers/utils/checkpoint_manager.py | Adds protected_checkpoint support during cleanup and fixes suffix parsing to correctly classify *-tmp checkpoints. |
| simpletuner/helpers/training/trainer.py | Implements post-save cleanup/rotation ordering, temp-only cleanup helper, protected checkpoint retention, explicit Hub upload path, rolling checkpoint refactor, and a distributed pre-save barrier for tempdir writes. |
| simpletuner/helpers/publishing/huggingface.py | Allows upload_latest_checkpoint() to accept an explicit checkpoint_path to avoid re-resolving latest in background upload tasks. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Checkpoint retention runs before
checkpoint_state_save(). Rotating first, then writing, isthe wrong order for every consequence below; this PR writes first and rotates after the save
succeeds, and protects the checkpoint that was just written.
Measured on
main(2d6df1245) by driving the realTrainer._run_standard_checkpointagainst aminimal stub:
limit + 1.CheckpointManager.cleanup_checkpoints(
checkpoint_manager.py:205-207) trims down to exactlylimit, and the save attrainer.py:5250-5252then adds one more. Withcheckpoints_total_limit=3over six cycles theon-disk count goes
[1, 2, 3, 4, 4, 4]. After the change it is[1, 2, 3, 3, 3, 3].lower step and continuing leaves the directory settled at
['checkpoint-400', 'checkpoint-5000']forever, because numeric rotation keeps the largest steprather than the one just written. If a later save then fails, the only complete checkpoint left
is the stale step-5000 one and recovery silently jumps 4900 steps. After the change the same
sequence settles at
['checkpoint-400'], and the failed-save case leaves['checkpoint-200', 'checkpoint-300-tmp'].checkpoints_total_limit=0deletes every checkpoint. The guard attrainer.py:5244ischeckpoints_total_limit is not None, so0passes through to a cleanup that reads it askeep-zero. Measured: three existing checkpoints plus one save leaves
['checkpoint-400']. Thefield registry documents the opposite —
field_registry/sections/training.py:198-212carriesValidationRule(MIN, 0, 'Must be non-negative (0 = unlimited)')and the tooltip"Set to 0 for unlimited". After the change all four are retained.
latestinstead of using the checkpoint it wasscheduled for.
huggingface.py:301callsfind_latest_checkpoint()inside asingle-worker background future (
trainer.py:2112,:5262); measured resolving tocheckpoint-200for an upload scheduled withglobal_step=100.-tmpdirectories are mis-parsed, and it destroys data. Suffix is read asparts[2](checkpoint_manager.py:249) /cs[2](trainer.py:5865), socheckpoint-200-rolling-tmpis classified as suffixrolling. Measured:cleanup_checkpoints(1, "rolling")deleted the realcheckpoint-200-rollingand kept theorphaned
checkpoint-200-rolling-tmp.One thing this PR also fixes, which is not reachable in a shipped configuration: the fallback
rotation path in
trainer.py(checkpoint_state_cleanup's non-manager branch,trainer.py:5878-5902) returns early whenlen(checkpoints) < limitand otherwise removeslen - limit + 1, so atlimit=1it deletes the only checkpoint before the save and a failedsave leaves nothing. That branch is dead today —
trainer.py:2206-2208constructs aCheckpointManagerwheneverconfig.output_diris truthy and the field default issimpletuner-results— so this is a latent correctness fix, not an observable bug. It is listedhere so the
- limit + 1→- limitchange is not mistaken for an off-by-one repair: that+1was the correct compensation for pre-save ordering, and it changes only because the ordering is
being inverted.
Changes
checkpoints only after the new save succeeds.
save_pathduring rotation so it survives even when its step numberis lower than another checkpoint on disk.
CheckpointManager.cleanup_checkpoints()and the fallback path take an optionalprotected_checkpointand use post-save retention counts.checkpoints_total_limit=0while leavingcompleted-checkpoint retention unlimited, matching the documented meaning of
0.latestin thebackground task.
*-tmpdirectories during cleanup (parts[-1]/cs[-1])._save_rolling_checkpoint()so the rolling path follows the same order as the standardpath.
Two changes that are not consequences of the above
Both are deliberate and both change runtime behaviour, so they are called out separately rather
than buried in the list.
A synchronous drain of pending Hub uploads before rotation. Rotation may delete a directory
that a background upload is still reading, so the new code blocks on
_drain_hub_upload_futures(wait=True)when the post-save count exceeds the limit. At steady statethat count is always
limit + 1, so this fires on every checkpoint cycle.mainalready has a blocking drain, but only in teardown:_finish_hub_uploads()(
trainer.py:2194-2198) is called from thefinallyof the run wrapper (:1908) and once moreafter the final model upload (
:7131). Inside the training loop the only drain istrainer.py:2189withwait=False, immediately before a new upload is scheduled. This PRintroduces the first blocking drain on the per-checkpoint path, and the uploader is a
single-worker
ThreadPoolExecutor(trainer.py:2108-2113), so a slow push serialises into thestep loop.
The cost is confined to runs with
push_to_hubenabled: with background push off,_hub_upload_futuresis empty and_drain_hub_upload_futuresreturns at its first line(
trainer.py:2149-2150). Reviewers who consider a per-checkpoint stall unacceptable should say so— the alternative is to accept that rotation can delete a directory mid-upload.
A pre-save barrier for all-rank temporary-directory writes. When DeepSpeed or FSDP has every
rank saving and
checkpointing_use_tempdiris set, the ranks must agree on the temp directorybefore any of them writes into it, so
checkpoint_state_savenow callsaccelerator.wait_for_everyone()in exactly that case. It is a no-op for single-process runs andfor main-process-only saving.
Ordering constraint: please merge #2988 first
Do not merge this before #2988 (
Fix RamTorch prefetch-order save race)._save_rolling_checkpointwidens the rolling-save gate tois_main_process or use_deepspeed_optimizer or fsdp_enable;main(trainer.py:6816-6825) has nofsdp_enablethere. With this PR alone, every FSDP rank newly entersaccelerator.save_stateonthe rolling path — and therefore reaches
save_hooks.py:1095, where all ranks still race on oneshared
ramtorch_prefetch_orders.json.tmp. Rolling checkpoints normally fire on a much tighterinterval than standard ones, so the exposure is not marginal. Merging #2988 first, or both
together, removes the interaction; the combined tree was verified green.
The two PRs touch disjoint files, so this is purely a sequencing constraint — there is no conflict
and no rebase needed either way.
Notes
checkpoints_total_limit=0remains unlimited for completed checkpoints while temp cleanup keepsrunning.
this branch.
Verification
CPU-only container, current
main(2d6df1245), 8 new test methods / 18 subtest cases intests/test_trainer.py.RED is
mainplus this branch's test changes with the three production files restored frommain.All 8 new methods are RED there — none is green-on-arrival. The 18-failure delta between RED and
GREEN on the full suite is exactly the new tests; the 24 that remain on both are pre-existing on
main.Each separable sub-fix was additionally mutation-tested by reverting it alone inside the green
tree, to confirm nothing is unpinned: