Repository navigation
Batch Rebalance Data Sampler - #47340
Conversation
New sampler that balances sequence length cost across DP ranks while
minimizing padding waste. Uses Poorman's Batch Rebalance algorithm with
configurable cost model.
Changes:
- trainer_pt_utils.py: Add BatchRebalanceSampler class
- training_args.py: Add 'batch_rebalance' to train_sampling_strategy
choices, batch_rebalance_alpha and batch_rebalance_max_tokens params
- trainer.py: Add batch_rebalance branch in _get_train_sampler
Usage:
TrainingArguments(train_sampling_strategy='batch_rebalance',
batch_rebalance_alpha=0.001)
Tests cover: - Basic correctness (coverage, micro-batch count) - No overlap between ranks within same global batch - Effective batch size invariant - max_tokens constraint enforcement - Determinism (same seed = same result) - Custom cost_fn override
PyTorch's Sampler base class doesn't accept data_source kwarg. Drop the super().__init__() call entirely (consistent with other HF samplers like LengthGroupedSampler).
…ens rebalance - Remove micro_batch_size from BatchRebalanceSampler: it was stored but never read anywhere in the class, and its name was misleading since it did not reflect the actual minimum per-group size used by the algorithm. - Fix a bug in _poormans_rebalance where counts[gi] -= 1 was applied unconditionally before searching for a donor group, causing samples to be silently dropped when max_tokens could not be satisfied and no group had spare capacity. Now the decrement only happens once a donor is found, and a warning is logged (with a fallback to exceed max_tokens rather than drop samples) when no donor is available. - Fix docstring typo (Poorman's -> Poor Man's). - Update/add tests: drop micro_batch_size from existing tests, add a regression test for the max_tokens data-loss fix, and rewrite the cost_fn test to directly assert on _cost() output instead of an unstable partition overlap comparison.
…wiring BatchRebalanceSampler yields a full batch of sample indices per iteration (BatchSampler semantics), but _get_dataloader() was passing it via DataLoader's sampler= kwarg together with batch_size=, which expects the sampler to yield one sample index at a time. This caused 'TypeError: list indices must be integers or slices, not list' as soon as train_sampling_strategy="batch_rebalance" was used with the Trainer. Also fixes a NameError (BatchEncoding was referenced in _get_train_sampler but never imported), which was the first failure hit before reaching the DataLoader wiring bug. Together these two bugs meant the batch_rebalance strategy could never actually run end-to-end through Trainer.train(); verified with a CPU multi-process (torchrun --nproc_per_node=4) benchmark on Qwen2.5-0.5B with a synthetic variable-length dataset after the fix.
When n < K * min_per_group, there are not enough samples to fill all groups. Previously this was silently patched by trimming counts (and a dead 'total < n' branch that could never trigger). Now it raises a clear error telling the user to reduce effective_batch_size or increase dataset size.
- Replace can_increase(cts, gi) with can_transfer(cts, from_idx, to_idx) which simulates the actual -1/+1 transfer to compute the correct start index and max_len for the token limit check. The previous implementation used stale positions that didn't account for shifts caused by the donor group shrinking. - When the lowest-cost group cannot accept a transfer due to max_tokens, iterate through remaining candidates by ascending cost instead of falling through to the nail-house branch or breaking entirely.
Each iteration of the rebalance loop moves exactly one sample between groups, so worst-case convergence is O(n). The fixed `K * 30` cap could therefore truncate before convergence for large `effective_batch_size` with a small `dp_size * grad_accum` (K), leaving the partition at a non-optimal cost spread. Use `max(K * 30, effective_batch_size)` as the budget: the `effective_batch_size` term covers the move-dominated regime (large batch, small K), while `K * 30` remains a floor for the detection-dominated regime (tiny batch, n ~= K) where convergence is reached via `spread == 0` / `seen` rather than the move budget. `best_counts` is still tracked, so hitting the cap only degrades the result gracefully (no samples are dropped). Adds a regression test (large n=246, small K=2, heavily skewed lengths) that asserts full sample coverage across all ranks and convergence to the brute-force-optimal contiguous K=2 cost spread.
GPU DDP benchmarkRan the sampler end-to-end through the Setup
Results
Step 5 sample distribution (step 5 = Reproduce: benchmark script is at https://gist.github.com/delock/516efc09d9a4217e209729e91b9294de ( |
|
| per_device_bs | group_by_length | batch_rebalance |
|---|---|---|
| 4 | 17.7 GB | 12.3 GB |
| 6 | 24.5 GB | 15.4 GB |
| 8 | 31.3 GB | 20.4 GB |
| 10 | OOM | 21.7 GB |
| 12 | OOM | 24.5 GB |
| 14 | OOM | 27.1 GB |
| 16 | OOM | ~30 GB (borderline) |
| 18 | OOM | OOM |
group_by_lengthOOMs atper_device_bs ≥ 10→ largest reliable batch = 8 (peak ~31 GB, right at the edge).batch_rebalancefits reliably up to 14 (peak ~28–30 GB); 16 is borderline (fits on short runs, OOMs over a full epoch); ≥18 OOMs.
→ batch_rebalance tolerates roughly 2× the per-device batch before OOM (8 → 14 reliable, 16 borderline; effective batch 8×2×2 = 32 vs 14×2×2 = 56), and at every batch size its peak is ~30–40% lower.
Why: group_by_length globally sorts by length, so the longest sequences land in a single micro-batch (per_device × 1024), and that one batch sets peak VRAM (the LM-head logits + activations dominate). batch_rebalance balances cost within each global batch, so the long-sequence group shrinks to just 2–3 samples → padded-tokens-per-micro-batch is bounded → no single batch spikes.
2) Throughput when each runs at its own max batch
| sampler | max per_device_bs | real tok/s | peak VRAM |
|---|---|---|---|
| group_by_length | 8 | 30,174 | 32.3 GB |
| batch_rebalance | 14 | 37,957 | 29.6 GB |
→ batch_rebalance delivers ~1.26× higher real-tokens/s (37,957 vs 30,174) — and does so at lower peak VRAM (29.6 vs 32.3 GB).
The gain is not from per-step efficiency (at equal per_device_bs the two are close). It comes from batch_rebalance being able to use a larger batch on the same GPU → better GPU occupancy → more real tokens/s. group_by_length is memory-capped at per_device_bs=8 by its long-batch spike; batch_rebalance converts the extra memory headroom into throughput.
Net: compared to group_by_length, batch_rebalance has lower peak VRAM at every batch size, tolerates ~2× the batch before OOM, and when each is run at its max batch it's ~1.26× faster in real tokens/s.
|
Thank you for your contribution 🤗! CI Security Gate — automatic approval blockedThis PR was not automatically approved for CI because the security gate failed. Possible reasons:
See the workflow run for the exact violations. A maintainer can review and manually approve CI if a finding is a false positive. |
|
|
||
| world_size = max(1, self.args.world_size) | ||
| rank = self.args.process_index if self.args.world_size > 1 else 0 | ||
| micro_batch_size = self.args.train_batch_size // max(1, self.args.world_size) |
There was a problem hiding this comment.
self.args.train_batch_size is the batch_size per rank
|
The optimization may be valid in isolation, but the current Trainer integration changes distributed sampling semantics. In DDP it appears to double-shard rank-local batches through Accelerate, reduces the configured per-device batch size by dividing args.train_batch_size by world_size, and ignores dataloader_drop_last=False, causing silent sample drops. These are correctness regressions in common Trainer usage, even if the sampler can improve performance in the narrow direct/single-process case. |
|
test example for your reference “accelerate launch --num_processes 2 example.py” |
Three review fixes for PR huggingface#47340: defeat double-sharding by neutralizing accelerate's BatchSamplerShard wrapper after prepare; fix effective_batch_size undercount (train_batch_size // world_size undercounts since train_batch_size excludes world_size in DDP); honor drop_last=False by yielding the padded trailing partial batch and passing dataloader_drop_last from the Trainer. Adds regression tests for all three.
|
@sywangyi thanks for your comments. The latest commit has the fix for three issues you mentioned, please kindly review, thanks! BTW, I have verified that the experiment environment actually contains the patches for the first and second issue in your comments, but they are omitted in the original commit. So the performance data is real. I reran the performance test with the patch I submitted and its consistent with previous result, thanks! |
…get_dataloader `sampler` was only assigned inside the non-IterableDataset branch, but the post-`accelerator.prepare` check referenced it unconditionally. Initialize `sampler` to None so IterableDataset runs no longer raise.
| # process), NOT per_device * world_size, so deriving micro_batch_size by dividing it by | ||
| # world_size undercounts by the world_size factor. Use per_device_train_batch_size | ||
| # directly: eff_bs = per_device_bs * grad_accum * dp_size. | ||
| effective_batch_size = self.args.per_device_train_batch_size * grad_accum * world_size |
There was a problem hiding this comment.
should be effective_batch_size = self.args.train_batch_size * grad_accum * world_size to cover DP case?
There was a problem hiding this comment.
Fixed. Thanks for the catch!
| if self.has_tail: | ||
| batch_indices = order[self.num_full_batches * self.effective_batch_size :] | ||
| if len(batch_indices) < self.dp_size * self.grad_accum: | ||
| pad = self.effective_batch_size - len(batch_indices) |
There was a problem hiding this comment.
The current implementation preserves a fixed effective_batch_size even for the final partial batch by replaying up to effective_batch_size - remainder samples. That is not the usual drop_last=False behavior; it can massively overweight duplicated examples. If fixed-size final steps are intentional, this should be documented and probably opt-in. Otherwise the tail should be padded only to the minimum size needed for rank/gradient-accumulation alignment, accepting a smaller final optimizer step.
There was a problem hiding this comment.
That's right, we don't need to pad so many samples, fixed.
|
The current regression test constructs BatchSamplerShard directly and then mutates its attributes, which verifies the wrapper behavior in isolation. It does not exercise the actual Trainer._get_dataloader -> accelerator.prepare(DataLoader(...)) -> patch prepared_dataloader.batch_sampler path. Since the fix depends on the exact structure returned by accelerator.prepare, could we add an integration test that builds the Trainer dataloader and verifies the prepared dataloader preserves the rank-aware BatchRebalanceSampler coverage? |
…dding, DDP integration test - trainer.py: effective_batch_size = train_batch_size * grad_accum * world_size (was per_device_train_batch_size * ...), so the DP (single-process, multi-GPU) case no longer drops the n_gpu factor. Covers both DDP and DP. - trainer_pt_utils.py: when drop_last=False and the remainder < dp_size*grad_accum, pad the tail up to K (dp_size*grad_accum) instead of the full effective_batch_size, avoiding heavy over-weighting of replayed samples in the final optimizer step. - test_trainer_data.py: extend effective_batch_size regression test to cover both DDP and DP; assert tail padding yields eff_bs + K (not eff_bs + eff_bs). - Add real DDP integration test (batch_rebalance_ddp.py + test_batch_rebalance_no_double_sharding) that exercises Trainer.get_train_dataloader -> accelerator.prepare -> patch path end-to-end under accelerate launch, verifying the structure-dependent wrapper detection and exact global sample coverage (no double-sharding drops).
execute_subprocess_async (the distributed test harness) treats a subprocess that produces no stdout/stderr as a failure. When launched with a full --config_file the script was silent on success, so the test raised 'produced no output'. Emit a one-line summary on the main process.
|
Thanks ! Will try to review this shortly |
There was a problem hiding this comment.
Thanks for your work @delock ! Happy to have this new sampler. Left a bunch of comments but I think most of them are easy fix and maybe a bit more iteration on the sampler to make it easier to understand, this will help a lot when it comes to maintaining it in the future ! Also, it would be nice to have some docs so that users learn about this feature !
| if is_torch_greater_or_equal_than_2_6: | ||
| dataloader_params["in_order"] = self.args.dataloader_in_order | ||
|
|
||
| sampler: torch.utils.data.Sampler | None = None |
There was a problem hiding this comment.
| sampler: torch.utils.data.Sampler | None = None | |
| sampler = None |
There was a problem hiding this comment.
Will fix as suggestd
| dataloader_params["drop_last"] = self.args.dataloader_drop_last | ||
| sampler = sampler_fn(dataset) if sampler_fn is not None else None | ||
| if isinstance(sampler, BatchRebalanceSampler): | ||
| # Pass as `batch_sampler`; it is mutually exclusive with `batch_size`/`sampler`/`drop_last`. |
There was a problem hiding this comment.
can you explain better why batch_sampler vs sampler here ?
There was a problem hiding this comment.
BatchRebalanceSampler decide how many samples a batch (or microbatch) will have based on a cost model. It tries to maintain close cost of the batch among ranks in the same iteration, so number of samples in each batch may change. sampler cannot achieve this goal because sampler return one sample per iter and there is no hint how many iters needs to be called to form the next batch. batch_sampler returns a list of samples per iter which can be used to form next batch.
| # `BatchRebalanceSampler` is already rank-aware: it assigns per-rank groups itself and each | ||
| # rank only iterates its own portion. `accelerator.prepare` unconditionally wraps every | ||
| # map-style `batch_sampler` in Accelerate's `BatchSamplerShard` (which keeps every | ||
| # `num_processes`-th batch per rank), so leaving it active would re-shard on top of the | ||
| # sampler's own per-rank yields -> double sharding that silently drops most samples each | ||
| # epoch. Make that wrapper a passthrough (num_processes=1) so the prepared dataloader | ||
| # iterates the rank-aware sampler directly. (The wrapper has plain settable attrs; we can't | ||
| # reassign `batch_sampler` on the underlying DataLoader after init.) |
There was a problem hiding this comment.
can you try to make this comment shorter and more comprehensible ?
| # `self.args.train_batch_size` is the per-process batch (per_device_train_batch_size * | ||
| # #GPUs in this process) and already excludes world_size. Multiplying it by grad_accum | ||
| # and world_size yields the true global effective batch size across all processes and | ||
| # accumulation steps, covering both DDP (world_size > 1, n_gpu == 1) and DP | ||
| # (world_size == 1, n_gpu > 1). |
There was a problem hiding this comment.
| # `self.args.train_batch_size` is the per-process batch (per_device_train_batch_size * | |
| # #GPUs in this process) and already excludes world_size. Multiplying it by grad_accum | |
| # and world_size yields the true global effective batch size across all processes and | |
| # accumulation steps, covering both DDP (world_size > 1, n_gpu == 1) and DP | |
| # (world_size == 1, n_gpu > 1). |
There was a problem hiding this comment.
we should update here for batch rebalance no ?
There was a problem hiding this comment.
Yes, we need to update for batch rebalance too for its current implementation.
| batch_rebalance_alpha: float = field( | ||
| default=0.001, | ||
| metadata={"help": "Quadratic cost weight for BatchRebalanceSampler (attention O(L^2) term)."}, | ||
| ) | ||
| batch_rebalance_max_tokens: int = field( | ||
| default=0, | ||
| metadata={ | ||
| "help": "Max padded tokens (bs * max_len) per micro-batch for BatchRebalanceSampler. 0 = unlimited." |
There was a problem hiding this comment.
I think that until some users request them, we can remove some args that I think nobody will really use. I think we can remove both of them but happy to keep one if you think it is really important to have to tune. But in your bench, I didn't see you run some ablation on top of them.
There was a problem hiding this comment.
Yes true, they should be added when there's real request. I'll remove then from this PR. Thanks!
| elif isinstance(lengths, torch.Tensor): | ||
| lengths = lengths.tolist() | ||
|
|
||
| world_size = max(1, self.args.world_size) |
There was a problem hiding this comment.
note that this will break when we add tp in it no ? maybe make a note that it is only tested with dp only so far
| # dropped even though the user asked to keep them. | ||
| self.has_tail = (not drop_last) and self.remainder > 0 | ||
| self.num_global_batches = self.num_full_batches + (1 if self.has_tail else 0) | ||
| self.total_steps = self.num_global_batches |
| def set_epoch(self, epoch: int): | ||
| self.epoch = epoch | ||
|
|
There was a problem hiding this comment.
Yes, it is needed so each epoch would reshuffle. Good catch that it was never called. I'll add a fix to call it at epoch boundary.
|
|
||
| cost(bs, max_len) = intercept + bs * max_len + alpha * bs * max_len^2 | ||
|
|
||
| where `bs` is the number of samples in the micro-batch and `max_len` is the length of the longest |
There was a problem hiding this comment.
Can you try to make this implementation a bit simpler to read ? There are a bunch of methods that doesn't have explanation or don't really need to be a method . The simpler the code is implemented or read, the better it is !
There was a problem hiding this comment.
Yes, the original code is too long. I'll break them into smaller methods and add comments for better understanding, thanks!
Signed-off-by: Guokai Ma <guokai.ma@gmail.com>
Per review: no ablation was run on these knobs and there is no user demand, so they do not belong in the public API. The quadratic cost weight is fixed at 0.001 (previous default) as an internal class constant; intercept and cost_fn were unused and are deleted along with the max_tokens machinery (feasibility repair loop, can_transfer, _padded_tokens, _within_token_limit). Output is bit-identical to the previous defaults, verified against the pre-change implementation over 30 randomized inputs. Signed-off-by: Guokai Ma <guokai.ma@gmail.com>
Per review: document and restructure the sampler internals. - _assign: explain the snake deal (groups ranked by cost, slot k takes ranks [k*dp_size, (k+1)*dp_size) so every slot has the same cost profile). - __iter__: deduplicate the full-batch loop and drop_last=False tail block into a single loop over global batches plus a _yield_global_batch helper. - _poormans_rebalance -> _balance_groups: drop the three redundant kwargs (derivable from self), document the hill-climbing search, and extract the freeze fallback into _slot_groups (pure: which groups share the capped group's slot) and _relieve_and_freeze (top up the slot from outside groups, then freeze it). Output verified bit-identical to the previous implementation over 286 randomized inputs, including adversarial freeze-prone configurations. Signed-off-by: Guokai Ma <guokai.ma@gmail.com>
Per review: keep it to a few unit tests and focus on the DDP integration test. - Merge basic/no_overlap/eff_batch_size/deterministic into one basic test asserting coverage, no cross-rank overlap, exact per-global-batch counts and seed determinism (original hand-picked lengths kept). - Merge the four drop_last tests into one test covering: tail dropped with drop_last=True, tail yielded as-is otherwise, padding up to K (not effective_batch_size) when the remainder is smaller, and order wrap-around when the whole dataset is smaller than K. - Drop test_batch_rebalance_defeats_double_sharding: it simulated accelerate's BatchSamplerShard by hand; the real path is covered by the DDP e2e test. - Extend the DDP e2e worker to actually train for two epochs and assert the per-epoch invariants end to end: each epoch covers all samples across ranks, set_epoch reaches the wrapped sampler (inner_epoch advances) and later epochs reshuffle. Validated on 2x RTX 5090. Every assertion from the removed tests maps into the consolidated ones. Signed-off-by: Guokai Ma <guokai.ma@gmail.com>
|
Hi @SunMarc , thanks for your review comments. I have updated the PR followup the comments. The balancing algorithm had been breakdown and commented to improve readability. Unused args without ablation is removed from arg list. Excessive UTs are removed and e2e test enhanced. |
SunMarc
left a comment
There was a problem hiding this comment.
Thanks a lot for updating ! LGTM, can you just fix one last thing in the test ?
| # Copyright 2026 The HuggingFace Team. All rights reserved. | ||
| # | ||
| # Licensed under the Apache License, Version 2.0 (the "License"); | ||
| # you may not use this file except in compliance with the License. | ||
| # You may obtain a copy of the License at | ||
| # | ||
| # http://www.apache.org/licenses/LICENSE-2.0 | ||
| # | ||
| # Unless required by applicable law or agreed to in writing, software | ||
| # distributed under the License is distributed on an "AS IS" BASIS, | ||
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| # See the License for the specific language governing permissions and | ||
| # limitations under the License. | ||
|
|
||
| """ | ||
| Worker script for the BatchRebalanceSampler DDP end-to-end test. | ||
|
|
||
| Runs under real DDP (launched by ``accelerate launch``/``torchrun``), trains a tiny model for | ||
| two epochs with ``train_sampling_strategy="batch_rebalance"``, and verifies the full stack: | ||
|
|
||
| 1. ``accelerator.prepare`` wraps the batch_sampler in a real ``BatchSamplerShard`` whose | ||
| inner ``.batch_sampler`` is our ``BatchRebalanceSampler``, and the Trainer neutralises |
There was a problem hiding this comment.
instead of recreating a new train script, can we kind of reuse the one we used for ddp/fsdp/deepspeed training (train.py in trainer/distributed/scripts folder )? If this requires a lot of changes, we can just copy the file and update it. This way we test on a real model.
There was a problem hiding this comment.
Thanks, I will reused the existing train.py, and add var length dataset capability to it (currently it will pad each sample to 128 tokens so rebalance won't take effect).
There was a problem hiding this comment.
For test_batch_rebalance_no_double_sharding, the training test alone cannot cover this because double-sharding will silently drop samples from the batch and current train.py cannot catch this situation which leads to a silent failure. Do you think we need a UT to catch this?
| def test_batch_rebalance_no_double_sharding(self): | ||
| """Regression for BatchRebalanceSampler double-sharding under real DDP. | ||
|
|
||
| ``accelerator.prepare`` wraps the already rank-aware ``BatchRebalanceSampler`` in | ||
| accelerate's ``BatchSamplerShard``, which would re-shard on top of it and silently | ||
| drop most samples each epoch. The Trainer neutralises that wrapper. Unlike the | ||
| unit test, this exercises the full path end-to-end -- ``Trainer.get_train_dataloader`` | ||
| -> ``accelerator.prepare(DataLoader(...))`` -> patch ``prepared_dataloader.batch_sampler`` | ||
| -- under a real multi-process launch, and checks the structure-dependent detection as | ||
| well as exact global sample coverage.""" | ||
| output_dir = self.get_auto_remove_tmp_dir() | ||
| script = os.path.join(SCRIPTS_DIR, "batch_rebalance_ddp.py") | ||
| num_processes = backend_device_count(torch_device) | ||
| cmd = self.get_accelerate_cmd( | ||
| script, | ||
| DDP_CONFIG_FILE, |
There was a problem hiding this comment.
up, just test that training works
Drop the bespoke batch_rebalance_ddp.py worker script and its white-box assertions in favour of the shared train.py, so the sampler is exercised against a real model end to end. train.py gains a --padding option because the default max_length padding makes every sample the same length, which leaves the rebalancing with nothing to do.
Hi SunMarc, thanks for your comments. I have update the code accordingly. Thanks! |
SunMarc
left a comment
There was a problem hiding this comment.
Thanks for iterating ! Last thing is it would be great to have some minor docs around this feature if possible.
|
The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update. |
stevhliu
left a comment
There was a problem hiding this comment.
Thanks for adding!
I think it may be better to add this as a short subsection to trainer_recipes.md (next to ## Dataloader performance) rather than training.md which is more of a basic fine-tuning guide where the defaults are expected to work reasonably well.
Some other little notes:
- brief sentence about the problem it solves would be nice (rank idle time from padding on variable-length data) and that its DP only
- maybe a snippet showing the config for it
per_device_train_batch_sizeis an average in this case and not a per-step count (may cause confusion if users see per-step batch sizes that don't match their config)- something about the length being precomputed and skipping the full-dataset length scan at startup may also be helpful
Move the batch_rebalance sampling strategy docs from training.md to a dedicated section in trainer_recipes.md, next to Dataloader performance, following review feedback. The new section explains the two imbalances it addresses (padding within a micro-batch and idle time across devices), how the sampler works (sort then shard), the average per_device_train_batch_size semantics, and how to skip the startup length scan with precomputed lengths.
Head branch was pushed to by a user without write access
|
Hi @stevhliu , thanks for the suggestion. I have moved the doc to |
CI recapDashboard: View test results in Grafana |
* Add BatchRebalanceSampler for cost-balanced distributed training
New sampler that balances sequence length cost across DP ranks while
minimizing padding waste. Uses Poorman's Batch Rebalance algorithm with
configurable cost model.
Changes:
- trainer_pt_utils.py: Add BatchRebalanceSampler class
- training_args.py: Add 'batch_rebalance' to train_sampling_strategy
choices, batch_rebalance_alpha and batch_rebalance_max_tokens params
- trainer.py: Add batch_rebalance branch in _get_train_sampler
Usage:
TrainingArguments(train_sampling_strategy='batch_rebalance',
batch_rebalance_alpha=0.001)
* Add unit tests for BatchRebalanceSampler
Tests cover:
- Basic correctness (coverage, micro-batch count)
- No overlap between ranks within same global batch
- Effective batch size invariant
- max_tokens constraint enforcement
- Determinism (same seed = same result)
- Custom cost_fn override
* Fix: remove invalid super().__init__ call in BatchRebalanceSampler
PyTorch's Sampler base class doesn't accept data_source kwarg.
Drop the super().__init__() call entirely (consistent with other
HF samplers like LengthGroupedSampler).
* Remove unused micro_batch_size param, fix silent data loss in max_tokens rebalance
- Remove micro_batch_size from BatchRebalanceSampler: it was stored but never
read anywhere in the class, and its name was misleading since it did not
reflect the actual minimum per-group size used by the algorithm.
- Fix a bug in _poormans_rebalance where counts[gi] -= 1 was applied
unconditionally before searching for a donor group, causing samples to be
silently dropped when max_tokens could not be satisfied and no group had
spare capacity. Now the decrement only happens once a donor is found, and a
warning is logged (with a fallback to exceed max_tokens rather than drop
samples) when no donor is available.
- Fix docstring typo (Poorman's -> Poor Man's).
- Update/add tests: drop micro_batch_size from existing tests, add a
regression test for the max_tokens data-loss fix, and rewrite the cost_fn
test to directly assert on _cost() output instead of an unstable partition
overlap comparison.
* Fix batch_rebalance sampling strategy: missing import and DataLoader wiring
BatchRebalanceSampler yields a full batch of sample indices per iteration
(BatchSampler semantics), but _get_dataloader() was passing it via
DataLoader's sampler= kwarg together with batch_size=, which expects the
sampler to yield one sample index at a time. This caused
'TypeError: list indices must be integers or slices, not list' as soon as
train_sampling_strategy="batch_rebalance" was used with the Trainer.
Also fixes a NameError (BatchEncoding was referenced in _get_train_sampler
but never imported), which was the first failure hit before reaching the
DataLoader wiring bug.
Together these two bugs meant the batch_rebalance strategy could never
actually run end-to-end through Trainer.train(); verified with a CPU
multi-process (torchrun --nproc_per_node=4) benchmark on Qwen2.5-0.5B with a
synthetic variable-length dataset after the fix.
* Remove unused variable flagged by ruff F841
* Fix import sort order for BatchRebalanceSampler (ruff I001)
* Apply ruff format to changed files
* Replace dead count-adjustment code with explicit ValueError
When n < K * min_per_group, there are not enough samples to fill all groups.
Previously this was silently patched by trimming counts (and a dead 'total < n'
branch that could never trigger). Now it raises a clear error telling the user
to reduce effective_batch_size or increase dataset size.
* Fix can_transfer accuracy and iterate all lo_candidates in rebalance
- Replace can_increase(cts, gi) with can_transfer(cts, from_idx, to_idx)
which simulates the actual -1/+1 transfer to compute the correct
start index and max_len for the token limit check. The previous
implementation used stale positions that didn't account for shifts
caused by the donor group shrinking.
- When the lowest-cost group cannot accept a transfer due to max_tokens,
iterate through remaining candidates by ascending cost instead of
falling through to the nail-house branch or breaking entirely.
* Scale BatchRebalanceSampler iteration budget with effective batch size
Each iteration of the rebalance loop moves exactly one sample between groups,
so worst-case convergence is O(n). The fixed `K * 30` cap could therefore
truncate before convergence for large `effective_batch_size` with a small
`dp_size * grad_accum` (K), leaving the partition at a non-optimal cost spread.
Use `max(K * 30, effective_batch_size)` as the budget: the `effective_batch_size`
term covers the move-dominated regime (large batch, small K), while `K * 30`
remains a floor for the detection-dominated regime (tiny batch, n ~= K) where
convergence is reached via `spread == 0` / `seen` rather than the move budget.
`best_counts` is still tracked, so hitting the cap only degrades the result
gracefully (no samples are dropped).
Adds a regression test (large n=246, small K=2, heavily skewed lengths) that
asserts full sample coverage across all ranks and convergence to the
brute-force-optimal contiguous K=2 cost spread.
* Fix BatchRebalanceSampler correctness issues in Trainer integration
Three review fixes for PR huggingface#47340: defeat double-sharding by neutralizing accelerate's BatchSamplerShard wrapper after prepare; fix effective_batch_size undercount (train_batch_size // world_size undercounts since train_batch_size excludes world_size in DDP); honor drop_last=False by yielding the padded trailing partial batch and passing dataloader_drop_last from the Trainer. Adds regression tests for all three.
* Fix UnboundLocalError on `sampler` for iterable datasets in Trainer._get_dataloader
`sampler` was only assigned inside the non-IterableDataset branch, but
the post-`accelerator.prepare` check referenced it unconditionally.
Initialize `sampler` to None so IterableDataset runs no longer raise.
* Address sywangyi review: effective_batch_size DP fix, bounded tail padding, DDP integration test
- trainer.py: effective_batch_size = train_batch_size * grad_accum * world_size
(was per_device_train_batch_size * ...), so the DP (single-process, multi-GPU) case
no longer drops the n_gpu factor. Covers both DDP and DP.
- trainer_pt_utils.py: when drop_last=False and the remainder < dp_size*grad_accum,
pad the tail up to K (dp_size*grad_accum) instead of the full effective_batch_size,
avoiding heavy over-weighting of replayed samples in the final optimizer step.
- test_trainer_data.py: extend effective_batch_size regression test to cover both DDP
and DP; assert tail padding yields eff_bs + K (not eff_bs + eff_bs).
- Add real DDP integration test (batch_rebalance_ddp.py + test_batch_rebalance_no_double_sharding)
that exercises Trainer.get_train_dataloader -> accelerator.prepare -> patch path end-to-end
under accelerate launch, verifying the structure-dependent wrapper detection and exact
global sample coverage (no double-sharding drops).
* Print summary line in batch_rebalance_ddp worker script
execute_subprocess_async (the distributed test harness) treats a subprocess that
produces no stdout/stderr as a failure. When launched with a full --config_file the
script was silent on success, so the test raised 'produced no output'. Emit a one-line
summary on the main process.
* Trim redundant rationale from effective_batch_size comment
* Apply ruff format to test_trainer_distributed_ddp
* Pad tail by wrapping order when dataset is smaller than K
order[:pad] returns at most len(order) elements, so for len(dataset) < K the tail
underfills and _poormans_rebalance(..., min_per_group=1) raises ValueError. Repeat the
order (math.ceil) to yield exactly pad padding indices. Adds an n=1, K=8 regression test.
* Address comments
Signed-off-by: Guokai Ma <guokai.ma@gmail.com>
* Remove batch_rebalance_alpha and batch_rebalance_max_tokens args
Per review: no ablation was run on these knobs and there is no user demand,
so they do not belong in the public API. The quadratic cost weight is fixed
at 0.001 (previous default) as an internal class constant; intercept and
cost_fn were unused and are deleted along with the max_tokens machinery
(feasibility repair loop, can_transfer, _padded_tokens, _within_token_limit).
Output is bit-identical to the previous defaults, verified against the
pre-change implementation over 30 randomized inputs.
Signed-off-by: Guokai Ma <guokai.ma@gmail.com>
* Simplify BatchRebalanceSampler for readability
Per review: document and restructure the sampler internals.
- _assign: explain the snake deal (groups ranked by cost, slot k takes ranks
[k*dp_size, (k+1)*dp_size) so every slot has the same cost profile).
- __iter__: deduplicate the full-batch loop and drop_last=False tail block into
a single loop over global batches plus a _yield_global_batch helper.
- _poormans_rebalance -> _balance_groups: drop the three redundant kwargs
(derivable from self), document the hill-climbing search, and extract the
freeze fallback into _slot_groups (pure: which groups share the capped
group's slot) and _relieve_and_freeze (top up the slot from outside groups,
then freeze it).
Output verified bit-identical to the previous implementation over 286
randomized inputs, including adversarial freeze-prone configurations.
Signed-off-by: Guokai Ma <guokai.ma@gmail.com>
* Consolidate batch_rebalance tests and extend the DDP e2e to two epochs
Per review: keep it to a few unit tests and focus on the DDP integration test.
- Merge basic/no_overlap/eff_batch_size/deterministic into one basic test
asserting coverage, no cross-rank overlap, exact per-global-batch counts
and seed determinism (original hand-picked lengths kept).
- Merge the four drop_last tests into one test covering: tail dropped with
drop_last=True, tail yielded as-is otherwise, padding up to K (not
effective_batch_size) when the remainder is smaller, and order wrap-around
when the whole dataset is smaller than K.
- Drop test_batch_rebalance_defeats_double_sharding: it simulated
accelerate's BatchSamplerShard by hand; the real path is covered by the
DDP e2e test.
- Extend the DDP e2e worker to actually train for two epochs and assert the
per-epoch invariants end to end: each epoch covers all samples across
ranks, set_epoch reaches the wrapped sampler (inner_epoch advances) and
later epochs reshuffle. Validated on 2x RTX 5090.
Every assertion from the removed tests maps into the consolidated ones.
Signed-off-by: Guokai Ma <guokai.ma@gmail.com>
* Reuse train.py for the batch_rebalance DDP test
Drop the bespoke batch_rebalance_ddp.py worker script and its white-box
assertions in favour of the shared train.py, so the sampler is exercised
against a real model end to end. train.py gains a --padding option because
the default max_length padding makes every sample the same length, which
leaves the rebalancing with nothing to do.
* [docs] Add batch_rebalance sampling strategy to the training guide
* [docs] Move batch_rebalance sampling guide to trainer_recipes
Move the batch_rebalance sampling strategy docs from training.md to a
dedicated section in trainer_recipes.md, next to Dataloader performance,
following review feedback. The new section explains the two imbalances
it addresses (padding within a micro-batch and idle time across
devices), how the sampler works (sort then shard), the average
per_device_train_batch_size semantics, and how to skip the startup
length scan with precomputed lengths.
---------
Signed-off-by: Guokai Ma <guokai.ma@gmail.com>
Co-authored-by: Marc Sun <57196510+SunMarc@users.noreply.github.com>
What does this PR do?
This PR introduce a training data sampler using a batch rebalance technique that reduces padding, balance cost between ranks and grad accumulation steps, which improves training efficiency.
Different from packed sequence data sampling, batch rebalance data sampling does not require varlen support in attention implementation. This makes it idea for researching or hardware that does not have varlen attention kernel implementation yet.
Different from group length sampler, this sampler does not require a global sort, so the sampler maintains true randomness in each effective batch.
How it works
The basic idea of batch rebalance data sampler is like this:
In distributed training, each rank processes a subset of samples called a micro-batch. When samples have different sequence lengths, the rank that happens to get longer sequences takes more time to compute, while other ranks finish early and wait. This idle waiting wastes GPU time.
Batch rebalance solves this by looking at all samples in a global batch, sorting them by length, and distributing them across ranks so that each rank gets a roughly equal computational cost — not an equal number of samples, but an equal workload. Short-sequence ranks get more samples; long-sequence ranks get fewer. The assignment is recomputed for every global batch using a lightweight greedy heuristic, with no communication between ranks.
For example, say you have 2 ranks, gradient accumulation of 1, and a global batch of 8 samples with token lengths [128, 256, 512, 768, 1024, 1536, 2048, 4096]. A random split assigns 4 samples per rank. If rank 0 happens to get [128, 512, 1024, 4096], every sample in that micro-batch is padded to 4096 — even the 128-token one — so rank 0 computes 4×4096 = 16,384 padded tokens. Meanwhile rank 1 gets [256, 768, 1536, 2048], padding to only 2048, computing 4×2048 = 8,192 tokens. Rank 0 takes twice as long, and rank 1 sits idle.
Batch rebalance sorts all 8 samples and assigns them in unequal counts: rank 0 gets just the 2 longest samples [4096, 2048] (2×4096 = 8,192 tokens), while rank 1 gets the remaining 6 shorter samples [1536, 1024, 768, 512, 256, 128] (6×1536 = 9,216 tokens). The workload is now nearly equal — no idle waiting.
Batch rebalance sampler uses a cost model to estimate the compute of each group of samples, then balances the total cost across ranks within the same synchronization step.
Specifically, for each global batch, the sampler sorts samples by length, partitions them into dp_size × grad_accum groups, and assigns groups to ranks so that ranks sharing the same gradient accumulation slot receive similar costs. Longer-sequence groups contain fewer samples; shorter-sequence groups contain more — but the estimated compute is even.
The default cost model is intercept + bs × max_len + alpha × bs × max_len², where bs is the number of samples in the group and max_len is the longest sequence. The quadratic term captures the super-linear scaling of attention. Users can plug in their own cost function via the cost_fn parameter to match their model's actual compute characteristics.
How to use
Set
--train_sampling_strategy batch_rebalancein your training command. No other changes to your dataset or model code are needed.python train.py
--train_sampling_strategy batch_rebalance
--per_device_train_batch_size 4
--gradient_accumulation_steps 2
Optional tuning parameters:
I confirm that this PR description and code is written with aid of LLM, and reviewed by a human being prior to PR submission.
Before submitting
[ ] (First-time contributors only): I confirm that this PR description and code is not written by an LLM or code agent
Pull Request checks?
to it if that's the case.
Who can review?
Anyone in the community is free to review the PR once the tests have passed. Feel free to tag
members/contributors who may be interested in your PR.