[Feature] Add --streaming_shard: split the streaming dataset across data-parallel ranks - #9860
Open
Rapisurazurite wants to merge 3 commits into
Open
[Feature] Add --streaming_shard: split the streaming dataset across data-parallel ranks#9860Rapisurazurite wants to merge 3 commits into
Rapisurazurite wants to merge 3 commits into
Conversation
…ata-parallel ranks Under `--streaming true`, only rank 0 iterates the dataset. It runs the whole cluster's tokenization / image encoding and then scatters one batch per rank via `scatter_object_list`. Global preprocessing throughput is therefore independent of world size, so adding ranks does not add data-supply capacity. The limitation is already documented in Command-line-parameters.md, and reported in modelscope#6758, modelscope#1939. This adds an opt-in `--streaming_shard` that splits the training stream across the data-parallel ranks, so each rank preprocesses only its own share. The split is by blocks of consecutive samples, sized to the amount of raw data the loader consumes per item it emits (the per-device batch size, or `packing_interval` when packing). That is the same rule accelerate's `IterableDatasetShard` and Megatron's `MegatronPretrainingSampler` use, and it means each rank receives exactly the blocks rank 0 would have scattered to it: without packing the per-rank sample sequence is unchanged. Rank, world size and block size are bound by the dataloader rather than at dataset construction time, because the relevant group is only known there: Megatron-SWIFT initializes its parallel state after the dataset is prepared, and with sequence parallel the relevant group is the dp group rather than WORLD. Binding in `DataLoaderDispatcher` lets DDP, sequence parallel and Megatron-SWIFT share one code path via the `rank`/`world_size`/`group` properties the subclasses already override. Since each rank consumes its own shard, the ranks can run out at different times, so a one-byte all_reduce per step stops them together. Signed-off-by: Lazurite <43494437+Rapisurazurite@users.noreply.github.com>
Block sizing makes the per-rank sample sequence identical to the default path only when packing is off. With packing, rank 0 scatters individual packs rather than whole packing rounds, so aligning the packing windows keeps the sample set but not the grouping. The docstring claimed the stronger property unconditionally. Signed-off-by: Lazurite <43494437+Rapisurazurite@users.noreply.github.com>
Document that exact per-rank assignment requires deterministic one-to-one preprocessing, and that first-exhausted may leave an unbounded tail when filtering or packing makes output batch counts uneven. Qualify dataset_num_proc and raw-read costs, reject the currently unsupported GRPO/GKD and AutoTP combinations, and replace eight multi-process tests with two focused tests that cover lazy binding, clean-stream equivalence, dataloader workers, resume skipping, and deliberately imbalanced packing. Signed-off-by: Lazurite <43494437+Rapisurazurite@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.
PR type
PR information
Adds an opt-in
--streaming_shardthat splits the streaming training set across data-parallel ranks, so each rank preprocesses only its own share instead of rank 0 preprocessing everything and scattering the batches.Problem
Under
--streaming true,DataLoaderDispatcherhas only rank 0 iterate the dataset; it runs the whole cluster's tokenization / image encoding and then scatters one batch per rank throughscatter_object_list(swift/dataloader/dispatcher.py). Because the encode is inside the streaming dataset (IterablePackingDataset, orEncodePreprocessor's lazymap), global preprocessing throughput does not depend on world size — adding ranks adds no data-supply capacity.This is already documented (
docs/source_en/Instruction/Command-line-parameters.md,--streaming: "preprocessing is only performed on rank 0 and then distributed to other processes ... can become a bottleneck") and reported in #6758 and #1939.Two details make it sharper:
num_procis only passed tomapfor a non-streamingHfDataset(swift/dataset/preprocessor/core.py), so the entire cluster encodes in one Python process.dataset_num_procencoder processes, but only rank 0's are ever fed; the rest idle for the whole run.Design
The split is by blocks of consecutive raw samples, sized to the amount of raw data the loader consumes per item it emits — the per-device batch size, or
packing_intervalwhen packing. This is the same block rule accelerate'sIterableDatasetShardand Megatron-LM'sMegatronPretrainingSampleruse for one-to-one samples. Because the default path already hands rankrther-th batch of every round, without packing the per-rank sample sequence is identical when preprocessing is deterministic and maps every raw sample to one output (test below). Samples dropped by truncation or bad media, expanded bytruncation_strategy=split, or randomly augmented can change the encoded assignment.With packing the guarantee is deliberately weaker: rank 0 scatters individual packs rather than whole packing rounds, so matching
packing_intervalonly lines the raw packing windows up. Each rank rebuilds its own packs and carries the leftover bin locally; pack grouping, pack counts, and finite-stream tail coverage can differ from dispatch.Rank, world size and block size are bound by the dataloader, not at dataset construction time, because that is the only layer that knows which group the data is parallelized over: Megatron-SWIFT initializes its parallel state after the dataset is prepared, and under sequence parallel the relevant group is the dp group rather than
WORLD. Binding insideDataLoaderDispatchermeans DDP, sequence parallel and Megatron-SWIFT all share one code path, via therank/world_size/grouppropertiesSequenceParallelDispatcherandMegatronDataLoaderDispatcheralready override — no fallback branch is needed, and ranks in the same TP/PP group naturally get the same shard.No new class is introduced: the sharded iteration is a second path inside
DataLoaderDispatcher, selected by whether a shard state was attached. The default path is unchanged.Because each rank consumes its own shard, ranks can run out at different times, so a one-byte
all_reduce(MAX)per step implements a first-exhausted policy and stops them together. This avoids a distributed hang, but when filtering or packing makes output batch counts uneven it can leave an unbounded tail on other ranks. Dataset shuffling normally reduces systematic imbalance, and step-based runs that reachmax_stepsbefore exhaustion are unaffected. For reference, neither of accelerate's non-dispatch paths gives us this:.shard()splits the source but has no cross-rank stop at all, andIterableDatasetShardgets equal batch counts by construction only because every rank materializes every rank's samples, which is exactly the cost this PR avoids. Note also that the current dispatcher gets lockstep termination for free by scatteringNone, so sharding must not silently drop it.sequenceDiagram participant S as Raw stream participant R0 as Rank 0 participant RN as Other DP ranks R0->>S: Read assigned raw blocks RN->>S: Read assigned raw blocks R0->>R0: Encode and pack locally RN->>RN: Encode and pack locally R0->>RN: Synchronize exhausted flags alt All ranks have a batch R0->>R0: Yield local batch RN->>RN: Yield local batch else Any rank is exhausted R0-->>RN: Stop iteration together endOpen design questions — feedback welcome
The current patch intentionally keeps the smallest policy surface, but I would appreciate maintainer guidance on two follow-ups:
first_exhaustedbecause it does not repeat data and preserves the lockstep termination guarantee of the existing dispatcher. A torchtitan-styleinfinitepolicy would remove the per-step collective and avoid truncating longer shards, but shorter shards would be repeatedly sampled and true epoch semantics would disappear. Would you prefer an explicitfirst_exhausted | infiniteoption, or keep the current single policy?block_size=packing_intervalaligns raw packing windows, but independently rebuilt packs can still have very different counts. Alternatives are sample-stride assignment for better statistical balance, or initially limitingstreaming_shardto non-packing data and handling packing in a follow-up. Is preserving the current packing support useful despite the documented first-exhausted semantics?Compatibility and limits
False; when--streaming false, it is reset with an info log. The existing dispatch path is unchanged.--deepspeed_autotp_size: the streaming path does not replicate a batch across tensor-parallel ranks. Note this predates this PR and also affects the default path —BatchSamplerShardis TP-aware viatp_sizebutDataLoaderDispatcherusesWORLD. Happy to fix that separately if you agree it is a bug.dataset_num_procencoders instead of only rank 0, so per-node CPU/memory can grow with (ranks per node) xdataset_num_proc. Without packing, streaming encoding remains single-process per rank.Tests
The test file was reduced from eight process-spawning tests (about 4 minutes) to two focused tests (49.93 seconds on the project container):
test_lazy_block_assignment: publicfilter(with_indices=True)remains unsharded until the dataloader binds rank/world size/block size, then selects the expected blocks.test_distributed_streaming_shard: one 2-rank process group covers deterministic one-to-one equivalence (including a partial tail andnum_workers=1), local resume skipping, and deliberately imbalanced packing. The packing assertion records the chosen first-exhausted policy explicitly: both ranks emit one pack, while the long-sample rank consumes 1 raw sample and the short-sample rank consumes 128; no bounded-tail guarantee is claimed.flake8/yapf/isortare clean with the versions pinned in.pre-commit-config.yaml.Data-pipeline benchmark (no model)
Multi-node pipeline throughput (2 nodes x Ascend 910B,
hccl, 50 ms/sample encode, no packing). This isolates the supply side, with no compute floor hiding it:--streaming_shard trueDoubling the ranks leaves the default path exactly flat (20.17 -> 20.19; per-rank rate halves, 1.26 -> 0.63 steps/s) while the sharded path doubles. Both match theory: one process at 50 ms/sample is 20 samples/s, and 16 / 32 x 20 = 320 / 640 vs 319.14 / 634.88 measured.