Resume a mid-epoch checkpoint on the same permutation - #4233
Open
caiotheodoro wants to merge 2 commits into
Open
Resume a mid-epoch checkpoint on the same permutation#4233caiotheodoro wants to merge 2 commits into
caiotheodoro wants to merge 2 commits into
Conversation
5 tasks
caiotheodoro
force-pushed
the
checkpoint-dataloader-mid-epoch
branch
from
September 9, 2026 01:22
be787bd to
f24824f
Compare
…_state
save_state never wrote any dataloader state: the sampler branch in
checkpointing.py required an IterableDatasetShard together with a
SeedableRandomSampler, which cannot happen. A resumed run therefore
reseeded the seedable sampler from epoch 0 (iteration was lost) and,
with the default sampler in a multi-process run, recreated the private
generator prepare_data_loader had attached (its state was lost).
Save iteration and the generator state per prepared dataloader in the
existing sampler{_i}.bin slot and restore them in load_state.
Checkpoints without the file load as before.
Fixes huggingface#3996
The sampler draws an epoch's permutation from its generator as soon as iteration starts, so a checkpoint taken inside an epoch holds the generator state of the next permutation. DataLoaderShard.__iter__ now keeps the state the epoch started from and save_state uses it while iteration still points at that epoch. A checkpoint taken while handling the last batch of an epoch counts as the end of that epoch. The dataloader skip_first_batches returns also hands its completed epoch, and that epoch-start state, back to the dataloader it was built from. Without that the epoch after the resumed one replayed the resumed epoch's permutation, since iteration was carried into the skipping dataloader (huggingface#4071) but never carried back.
caiotheodoro
force-pushed
the
checkpoint-dataloader-mid-epoch
branch
from
September 9, 2026 01:46
f24824f to
e320a5c
Compare
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.
Stacked on #4232, the first commit is that PR, only the second one is new here.
After #4232 an epoch-boundary checkpoint resumes fine, but one taken inside an epoch doesn't, with the default sampler.
RandomSamplerdraws the permutation as soon as iteration starts, so by the timesave_stateruns mid-epoch,synchronized_generatoralready holds the state for the next epoch. Restore that, callskip_first_batches(dl, n), and you skipnbatches of a permutation you never trained on. The seedable sampler was already fine here, its seed isinitial_seed + epochand doesn't care when you saved.Then there's a second thing I only noticed while testing this, and it hits both samplers.
skip_first_batchesbuilds a freshDataLoaderShardwith the source'siteration(that's #4071), but the source never hears that the epoch finished. Your loop goes back to the original loader for the next epoch,__iter__callsset_epoch(k)on it again, and you get permutationktwice in a row.examples/by_feature/checkpointing.pyhas exactly this shape.What changed:
DataLoaderShard.__iter__records(iteration, generator.get_state())right before it creates the base iterator, andsave_stateuses that pair as long asiterationstill points at the same epoch, the live state otherwise. One case needed its own rule: saving while handling the last batch of an epoch.iterationonly moves after the finalyield, so that checkpoint looked like "inside epoch k" and resumed by replaying epoch k, with every later epoch off by one. The example hits it whenevercheckpointing_stepsdivides the batches per epoch, and the checkpoint looks fine.end_of_dataloaderis already set before that last batch is handed out, sosave_statetreats it as the end of the epoch. Test for it in both test files.skip_first_batchessets_source_dataloaderon the loader it returns,_finish_epochwrites the incrementediterationback to the source, and_record_epoch_startmirrors the epoch-start state onto it, so crashing a second time inside the resumed epoch works too (I ran that one: save 3 batches in, resume, save 2 batches later, resume again, rest of the epoch and the next one match on both ranks). Nestedskip_first_batchescalls chain to the original loader. The write-back only happens when the source is still one epoch behind, and the recorded epoch-start state is dropped when an epoch completes and whenload_stateruns, so asave_stateright afterload_statewrites what was loaded, not what this process happened to iterate before.Nothing changes for a run that never resumes:
_record_epoch_startonly callsget_state(), it consumes no RNG, so an uninterrupted run produces the same order as before this PR, byte for byte. Only resumed runs move.use_stateful_dataloader=Truewith the default sampler gets the same fix for free. torchdata fast-forwards the sampler by re-iterating it, so restoring the epoch-start generator state makes it redraw the same permutation, and then the rest of the epoch and the next one match on both ranks. On #4232 alone that path is wrong on both counts. That's the config the checkpointing example uses with--use_stateful_dataloader.Still not exact, and left alone here: the paths where the permutation comes from a generator Accelerate doesn't hold, so the default sampler in a single process (global RNG, or torchdata's own generator with
use_stateful_dataloader) and the default sampler withdispatch_batches=True(global RNG on the main process).load_staterestores the global RNG as of the checkpoint, so a mid-epoch resume there gets a fresh permutation for the interrupted epoch, same as plain PyTorch. Epoch-boundary resume on those paths is fine after #4232. There are two more things on the dispatcher I'll send separately, both pre-existing:skip_first_batcheson aDataLoaderDispatcherwraps the unsharded batch sampler, so it skipsnglobal batches instead ofnper-rank ones, andDataLoaderDispatcher.set_epochonly looks atbatch_sampler.sampler, which aSkipBatchSamplerdoesn't have, so the seedable sampler's epoch never reaches the skipping loader. Either one makes the rest of a resumed epoch wrong underdispatch_batches=True(the following epoch is right).Checked on CPU, torch 2.14.0, 2 processes over gloo: save after 3 batches of epoch 2, resume with
skip_first_batches(dl, 3), compare the rest of epoch 2 and all of epoch 3 against the uninterrupted run.One design question rather than a change: the single-process default path could get the same private generator
prepare_data_loaderalready attaches in the multi-process branch. Every non-dispatch path would then resume exactly and the gate in the tests would go away. The cost is that a single-process run's epoch-0 order would change versus earlier releases, since the seed would be drawn atprepareinstead of the first__iter__. I didn't do it here because of that; happy to if you'd rather have one code path.Drafted with Claude Opus / Fable 5.1. Reviewed by Muse Spark 1.3 and GLM 5.3 as judges before submission.
Before submitting
Pull Request section?
to it if that's the case.
documentation guidelines, and
here are tips on formatting docstrings.
Who can review?
@SunMarc