fix(data): reject a rank the epoch-rollover re-split left with no samples - #2995
Merged
Merged
Conversation
Contributor
There was a problem hiding this comment.
Pull request overview
This PR adds a post-split validation during Trainer._epoch_rollover to fail fast when the per-epoch re-bucketing + re-splitting leaves a given rank with zero samples, aligning rollover behavior more closely with startup-time dataset validation and avoiding downstream “empty schedule” failures.
Changes:
- Add a rank-local guard after
split_buckets_between_processes()during epoch rollover to raiseValueErrorwhen the local shard contains zero samples. - Extend unit tests to cover both directions: reject a truly empty shard, but allow shards that have samples even if they can’t form a full local batch.
- Adjust an existing epoch-rollover padding-policy test fixture to provide non-empty
aspect_ratio_bucket_indices(so it doesn’t trip the new guard).
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
simpletuner/helpers/training/trainer.py |
Adds a post re-split “zero samples on this rank” failure guard during epoch rollover. |
tests/test_trainer.py |
Adds targeted unit tests for the new rollover guard and updates an existing test fixture to remain valid. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
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
The bucket re-split that runs at every epoch rollover can leave a rank with no samples, and nothing
checks for it. Startup refuses the identical dataset state.
Problem
_handle_bucket_operationsalways ends by calling_handle_config_versioning(factory.py:3359),which closes with a check that raises
ValueError: Dataset produced no usable sampleswhen the splitleft this rank's backend with nothing usable (
factory.py:3437-3476).Trainer._epoch_rolloversplits a second time (trainer.py:5536) and has no equivalent follow-up.The only diagnostic on that path is the warning at the end of
split_buckets_between_processes(
base.py:945-952), and it is gated onshould_log()— global rank 0 — while the rank that ends upempty is not rank 0.
Measured with 16 images across 8 ranks,
train_batch_size=1,gradient_accumulation_steps=1,repeats=1,--max_train_steps=100, oversubscription off:{1.0: 8, 1.5: 8}{1.0: 4, 1.5: 4}{1.0: 4, 1.5: 4}Dataset produced no usable samplesNo data has to be deleted to get there.
crop_aspect="random"is what puts a backend on this codepath in the first place, and re-bucketing the same 16 images is enough.
Reproduction condition. The pre-split validation inside
split_buckets_between_processes(
base.py:787-870) does run at rollover, so what is missing is specifically a post-split check.Staying silent requires
repeats >= train_batch_size * gradient_accumulation_steps; with the defaultrepeats=0every rank is caught earlier byDataset configuration will produce zero usable batches.What happens afterwards. Not nothing. With prefetch off the emptied rank raises from
MultiAspectSampler._reset_buckets(sampler.py:408) at the first fetch of the new epoch — anunattributed
No images found in the datasetfrom a rank that started fine, some distance from thesplit that caused it. Reading the prefetch path suggests that with
--dataloader_prefetchthe sameexception is swallowed into a DEBUG record (
batch_fetcher.py:83-85); I have not verified thatvariant on hardware.
Resolution
Ten lines directly after the re-split in
_epoch_rollover: if this rank's buckets hold no samples atall, raise, naming the backend and the epoch it was about to enter.
Nothing else moves.
factory.pyis untouched, and the check does not import from or call into thestartup path, so the two guards stay independent — that is deliberate, and it is why the message is
written out here rather than shared. The only reason to couple them would be to keep one predicate,
and this PR does not want the startup predicate.
Raising rather than logging: the trainer already aborts mid-run by raising — a
Nonebatch(
trainer.py:6481) and an unrecognised gradient-clipping method (trainer.py:6694) raiseValueError, a non-finite loss (trainer.py:6602) raisesRuntimeError. A rank whose schedule isempty cannot contribute a gradient for the epoch it is entering.
The message opens with
Dataset produced no usable samples, the same phrase startup uses, so anoperator grepping logs or an existing alert finds both. The rest of it is rollover-specific: which
epoch, and the causes that actually apply here — per-epoch re-bucketing under
crop_aspect=random,or samples filtered out of the cache during the previous epoch.
The remedies it suggests are the ones that move a physical shard boundary.
--allow_dataset_oversubscriptionturns padding on (trainer.py:5538), which raises every non-emptybucket's shard to
ceil(len / dp_size)and so guarantees at least one sample per rank; fewer GPUslowers the divisor; more samples raises the dividend. It deliberately does not repeat startup's
advice to lower
batch_size/gradient_accumulation_stepsor raiserepeats: none of those reachdivmod(len(trimmed_images), effective_dp_size).trim_limitis always>= len(images)(
base.py:903-906), sotrimmed_imagesis the full bucket regardless of effective batch size, andrepeatsonly multiplies the sampler's logical passes over a shard that is already empty.This guard is deliberately narrower than the startup one
It asks a literal question — did this rank end the split with zero samples — and nothing more.
The startup check uses
len(metadata_backend) == 0, which counts buckets able to form a completelocal batch (
discovery.py:767-780). Reusing that here would also abort a rank that still holdssamples but too few to fill one
train_batch_size. Such a rank keeps training today on recycledsamples, and it keeps doing so after this PR. Aborting a run that is currently making progress is a
larger intervention than the defect requires, so the narrow predicate is the conservative choice:
the only state that newly aborts is the one that is already unusable.
The consequence is that this does not make the rollover fully agree with startup. A sub-batch shard
is still accepted here and refused there. That is a smaller divergence than the one being fixed, and
closing it means changing what startup considers fatal — a separate decision, not this PR's.
The startup check's
missing_instance_direscape hatch (factory.py:3437-3440) is not carried over.It exists to tolerate a backend whose instance directory is not a local path — for
type: awsit isthe S3 key prefix (
factory.py:3032-3033), andos.path.exists()on that is always False. That is aquestion about configuration, and it has already been answered by the time an epoch has finished
training. Whether the samples live on local disk or in a bucket has no bearing on whether this rank's
shard came back empty. Leaving it out also means the rollover guard actually covers remote backends,
where the startup one silently does not.
What this does not do
The guard is rank-local, exactly as startup's is: any rank that still has data proceeds. It is not
only the partial case — a re-bucketing that empties the dataset globally reaches this check on every
rank, because the pre-split validation skips empty buckets (
base.py:790-791) and so does not catchit first. Whether the ranks that survive a partial case then block on a collective is not something
I can demonstrate — the reproduction is an 8-rank
simulation inside a single process — so no claim is made about hangs or NCCL timeouts.
It also leaves the rollover's padding policy alone.
apply_padding=(self.config.overrode_max_train_steps or self.config.allow_dataset_oversubscription)at
trainer.py:5538is a correct carry-forward ofnot args.max_train_steps, andtest_epoch_rollover_reuses_initial_bucket_padding_policyalready pins it.Tests
tests/test_trainer.pytest_epoch_rollover_rejects_a_rank_the_resplit_left_empty— recorded RED onmain(
AssertionError: ValueError not raised). It also asserts the rejection lands after the re-splitand before
rebuild_cache(), so a bad schedule is never handed downstream.test_epoch_rollover_keeps_a_rank_that_cannot_fill_a_batch— the other direction. One sampleagainst
batch_size=4, solen(metadata_backend)reads 0 and the startup predicate would fire.This rank must keep going. Passes before and after, and fails if the predicate is ever widened.
test_epoch_rollover_reuses_initial_bucket_padding_policygains one fixture line: itsMagicMockbackend now carries an
aspect_ratio_bucket_indicesdict, because a bareMagicMockiterates emptyand would read as a zero-sample shard. Its assertion is unchanged.