Repository navigation
fix(checkpoint): stop training at the next step after a failed background save - #4729
Open
Thanatos9404 wants to merge 1 commit into
Open
Thanatos9404 wants to merge 1 commit into
Thanatos9404 wants to merge 1 commit into
Conversation
…ound save Background checkpoint finalization stores its error in CheckpointManager._finalize_error, and only finalize_pending() raises it. The training loops reach that at the next save or at exit, so a run kept training for up to save_period steps after an async write or the tmp_step_N rename had failed. Add CheckpointManager.raise_if_finalization_failed(), which raises an error the finalize thread has already recorded without waiting for a finalization that is still running, and call it at the start of every step in the training loops that use begin_finalization(). The async write keeps overlapping with training; a failed save now stops the run at the next step boundary. The single controller skips the poll while a save holds _checkpoint_save_lock, because that save may be flushing finalization from a worker thread and two threads must not flush at once. A skipped poll is caught at the next step. Signed-off-by: Yashvardhan Thanvi <yashvt9404@gmail.com>
Thanatos9404
force-pushed
the
fix-checkpoint-finalize-error-timing
branch
from
October 16, 2026 11:49
9ba0371 to
077ff91
Compare
This branch has not been deployed
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.
What does this PR do ?
Raise a failed background checkpoint finalization at the start of the next training step instead of at the next save.
Issues
Fixes #4707
Usage
N/A (internal). No config or API changes for users.
Problem
With background finalization, a checkpoint whose async write or
tmp_step_Ntostep_Nrename fails does not stop the run. Training keeps going until the next save, which is up tosave_periodsteps later, and only then raises. By that point the newest visiblestep_Nis older than the step the run has reached, and the error is reported from a later step's save than the one that broke.Root cause
CheckpointManager.begin_finalization()runswait_fnand_rename_checkpoint()on a daemon thread. On failure the thread only stores the exception inself._finalize_error(checkpoint.py#L439). The only code that raises it isfinalize_pending()(#L446-L458), and the training loops reach that only:finalize_pending()call at the top ofbegin_finalization()(#L422) or the explicitcheckpointer.finalize_pending()the GRPO loops make beforeinit_tmp_checkpoint()(grpo.py#L4118, #L6010), orshutdown().Nothing looks at
_finalize_errorbetween saves.Change
CheckpointManager.raise_if_finalization_failed(): if the background thread has already recorded an error, it callsfinalize_pending(), which joins the finished thread, clears the state and raises the sameRuntimeError("Background checkpoint finalization failed")with the original exception as its cause. It never waits for a finalization that is still running.begin_finalization()calls it at the top of each step: GRPO (grpo_trainandasync_grpo_train), GRPO over TransferQueue (grpo_sync.py), SFT, SFT v2, DPO, RM, PPO (ppo_trainandasync_ppo_train), distillation, cross-tokenizer off-policy distillation, and the single controller train pump. In the async loops it sits right after the existing per-stepcheck_healthcall; in the single controller it runs before themaybe_gpu_profile_stepcalls.finalize_pending()on a worker thread (viaasyncio.to_thread) while it holds_checkpoint_save_lock. The train pump skips its poll while that lock is held, so two threads never flush at once; a skipped poll is caught at the next step. The call stays synchronous (noto_thread), so thelocked()check and the poll are atomic on the event loop.begin_finalization()call sites now say that a failure that has already happened is raised at the start of the next step.Why a non-blocking check
The issue lists a few options. I went with a check that does not block because of what async save is for:
async_savewas added for. Withsave_period > 1the write currently has several steps to finish._finalize_erroronce per step costs nothing when saves succeed, keeps the async write overlapping with training, and reuses the existing error path, so the exception type and message do not change.finalize_pending()before the next save andshutdown()at exit stay the hard barriers they are today.The trade-off: a write that is still running when the next step starts is not waited for. If it fails during that step, the failure surfaces at the following step boundary (or at the next save or exit, whichever comes first). So the delay is bounded by the step in flight when the write fails, instead of by the save period.
Tests
New tests:
tests/unit/utils/test_checkpoint.py::TestRaiseIfFinalizationFailed: raises a failure that already happened (original exception kept as__cause__,tmp_step_1left in place, raised only once); does not block while a finalization is in flight; no-op when nothing failed.test_grpo.py::test_grpo_failed_checkpoint_finalization_stops_before_next_step[grpo_train]and[async_grpo_train]: a realCheckpointManager,save_period=2,max_num_steps=6, andpolicy.finalize_async_saveraisingOSError. Asserts the run raises after exactly 2 training steps and thattmp_step_2was never renamed.test_checkpointing.py::TestAsyncSaveFinalization::test_failed_background_finalization_stops_before_next_step: the same scenario through the single controller train pump (save_period=2,max_num_steps=4), asserting it stops at_train_steps == 2.test_single_controller_actor.py::test_train_pump_polls_finalization_only_while_no_save_holds_the_lock: no poll while the lock is held, one poll once it is free.test_failed_checkpoint_finalization_stops_before_trainingintest_sft.py,test_dpo.py,test_rm.pyandtest_distillation.py: with the mocked checkpointer'sraise_if_finalization_failedraising, the loop raises andtrainis never called.The GRPO and single controller loop tests join the finalize thread before the next step starts, so the failure has already happened when training reaches the step boundary, as in the issue's trace. On main, all 11 new tests fail: both GRPO loops and the train pump run steps 3 and 4 and only fail at the step-4 save (
assert 4 == 2), the four wiring tests fail withDID NOT RAISE, the lock test sees no call, and the threeCheckpointManagertests hitAttributeError.Edits to existing tests:
test_failed_background_finalization_raises_at_next_saveis now..._raises_by_next_save, with its comment saying the failure surfaces no later than the step-4 save; the assertion is unchanged._train_pump_controllerintest_single_controller_actor.pybuilds the actor withobject.__new__and sets each attribute the pump reads. It now also sets_checkpointer = MagicMock(), since the pump reads it every step.Commands and results (CPU only, Python 3.13.14, torch 2.13.0+cpu, `HF_HOME` set; not the CI container)
Compatibility
CheckpointManager. No config changes and no change to the checkpoint layout.tmp_step_Nstays in place as before (resume still ignores it).MagicMockorCheckpointManager) needs the new method; there are none in the repo.CheckpointManager.__init__to read an attribute config (callers passCheckpointingConfig.model_validate(...)). The new GRPO test builds aCheckpointManagerfrom a plain dict (test_grpo.pyaround L4270), so whichever PR lands second needs a one-line adapt there. The other new tests go through existing fixtures and config builders.Before your PR is "Ready for review"
Pre checks:
Additional Information
CI:L0(bug fix).latest_checkpoint_status.json(last_successful_ckpt_save_completion) right afterbegin_finalization(), before the rename has happened, so a save whose finalization later fails is still recorded there as successful. Happy to send a follow-up if that file is meant to track finalized checkpoints only.