Repository navigation
[OMNIML-6059] [1/21] Add the DP=EP all-to-all expert transport - #2738
Conversation
|
Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually. Contributors can view more details about this message here. |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #2738 +/- ##
==========================================
- Coverage 79.80% 79.11% -0.69%
==========================================
Files 660 661 +1
Lines 74792 74928 +136
==========================================
- Hits 59688 59282 -406
- Misses 15104 15646 +542
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
d40b1c1 to
cd7e9a6
Compare
|
|
/ok to test cd7e9a6 |
|
/ok to test 69f3a98 |
69f3a98 to
38c164d
Compare
38c164d to
e301775
Compare
|
/ok to test e301775 |
e301775 to
7267471
Compare
|
/ok to test 7267471 |
7267471 to
5a6426d
Compare
…s' fused experts Split over two ranks, Qwen3-MoE's fused experts must give the whole module's output and gradients -- for the hidden states, the routing weights and each rank's own experts -- through the registered torch_a2a forward, the path expert-parallel training and QAT run. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Spawning two ranks costs a unit test about 14 s on CI, far more than its job. They now run on cpu_dist_workers, the two gloo ranks the unit tests keep up for the session. The workers now reseed after every job, as freshly spawned ranks would start it: a job that draws a different amount of randomness on each rank otherwise leaves the next job's ranks out of step, which failed two fused-expert tests on the transformers 5.5 lane. The one-rank case runs as a one-rank expert-parallel group on each worker. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The transport cast the hidden states to bf16 and handed bf16 back, so an fp16 model's residual stream promoted to fp32 and its next linear failed, and an fp32 model ran its experts on bf16-rounded tokens. Tokens now travel in the model's dtype: bf16 models are unchanged, and the per-token combine still accumulates in fp32. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The reseeding teardown starts every pool job from the same seed on both ranks, so test_data_parallel calibrated both ranks on the same batches. DDP gives them the same weights too, so its group-MAX check held whether or not calibration synced the amax across data-parallel ranks. Drawing the data from a per-rank seed makes the ranks' input amaxes differ, so the check fails without the sync. The check now runs after every rank has joined every all_reduce. A rank that stopped at its first mismatch would leave its peer waiting in the next one, hanging the pool's workers instead of failing the test. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
test_a2a_moe_forward_backward ran a2a_moe through a hand-written expert function against a dense reference (with its _dense_moe and _router helpers). test_fused_experts_backward_through_torch_a2a_matches_the_whole_module sends the same hops through transformers' real experts and checks the same forward and the hidden, routing-weight and expert gradients; the one-rank subgroup is run by the dtype test. Detaching the routing-weight or hidden-state hop, leaving the returned pairs unpermuted, or dropping the routing weights each fails a remaining test. The module docstring and the dtype comment now describe the comparison that remains: transformers' fused experts, split over the ranks, against the whole module on one process. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
A transfer past the int32 element limit went as ceil(rows * H / limit) even slices of the hidden dim. Rounding each slice's width up adds up to `rows` elements to it, so a hop could still pass the limit (Q = 1572863 rows of H = 4096 make three 1366-wide slices of 2148530858 elements) and hang the NCCL collective. A slice is now as wide as the most rows any rank moves allow, limit // rows, which bounds every hop directly. That row count is all-reduced once per layer, after the count exchange, for both 2-D hops (the dispatch and the combine); each hop no longer runs its own all_reduce and host read of the transfer size. The chunking test now routes every rank's 12 rows evenly and records the size of each 2-D hop: under a limit of 34, the old even split moves 36 elements per hop and fails it. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
`warnings` is already a module import, and torch.distributed.nn.functional has no circular-import or weight reason to stay local (it adds about 3 ms to the module's import, and no warning: only calls are deprecated). Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
a2a_moe only passed its arguments on to _a2a_moe_torch, and neither read num_global_experts: an expert's owner rank follows from the local count alone. The transport is now a2a_moe itself, without that parameter. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
`assert grouped_mm_experts_forward is not None` held whenever the import did; it never showed the whole-module reference ran through grouped_mm, and the comparison does not depend on the path the reference takes. The import goes with it. packaging is a core dependency, so Version is imported at the top; the transformers imports stay in their functions, now with the reason: the transformers-free unit lane collects this file. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
transformers 5.16.0 exports the router parallel style only as EpRouterParallel; 5.16.1 restored RouterParallel as an alias. On 5.16.0, which the 5.6 floor admits, register_torch_a2a raised an ImportError before registering the transport. It now falls back to EpRouterParallel. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
With every token routed to rank 0's experts, rank 1 runs transformers' fused experts on an empty batch. The forward and the gradients still match the whole module's on every rank, and the experts no token reached get no gradient. The shared helper now asserts that, instead of a non-zero gradient on every rank. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
… fp32 slices a2a_moe cast the routing weights to the hidden states' dtype before weighting the experts' results. A router that returns fp32 weights for bf16 experts, as Mixtral's does, then got a bf16 product where transformers' grouped_mm forward computes an fp32 one, so torch_a2a changed the model's output even at EP=1. The weights now never travel: the experts' results come back unweighted, and the rank that holds each token multiplies them by its routing weights in the weights' own dtype. That drops the routing-weight all-to-all, and the weights' gradient comes from the local product. The combine then accumulated in fp32 by converting all [tokens * top_k, hidden] results at once, a full fp32 copy beside the transport's buffers: 512 MiB at 4096 tokens, hidden 4096 and top_k 8, and GiBs at the calibration batches the chunked transport serves. It now weights and sums a slice of rows at a time, at most 2**25 fp32 elements (128 MiB) each, with the same result. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
a2a_moe kept all six of its [rows, hidden] buffers (the sent tokens, the received ones, the experts' output in two orders, and the combine's two) until it returned, though each is dead once its stage is done. Together they were the layer's peak memory. Dropping each as its stage finishes cuts the peak RSS growth of one layer at 4096 tokens, hidden 4096 and top_k 8 from 1995 to 714 MiB on CPU; the result is unchanged. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The bounded combine sums the routed results a slice of rows at a time. On a rank whose routing slots are all -1 it ran no slice, so its output lost its autograd link to the combine, and its backward skipped the reverse all-to-all its peers wait in. It now always runs at least one slice, empty on such a rank, which keeps that link; forward results are unchanged. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
meenchen
left a comment
There was a problem hiding this comment.
One minor comment, approve to unblock
|
/ok to test 6eca1d7 |
sychen52
left a comment
There was a problem hiding this comment.
A few comments on the transport at 6eca1d7. They come from reading the code; nothing was run.
- [P3]
tests/unit/torch/quantization/test_autoquant.py:956(outside the diff)._test_data_parallel_auto_quantizebuilds its data withmodel.get_input(), which draws from the global RNG, so both ranks get the same batches. Its "same costs and scores on all ranks" check then passes even without the data-parallel score sync. That was often true before too, because the workers start from seed 1234, but the reseed after every job now makes it certain. It's the same gap this PR closes intest_dist.py, and atorch.manual_seed(rank)right afterSimpleLinear()would make the check meaningful there too. The other pooled data-parallel tests are fine as they are.test_data_parallel_moe_score_moduleandtest_data_parallel_aumann_shapleyuse the same data on every rank on purpose, and they check the reduced values against a single-rank reference, so a dropped reduction still fails them. - Comment style. Several new comments read like incident notes: CAPS for emphasis, "observed: … all_to_all ran the full 3h NCCL timeout", "deterministic on the qwen3_5_moe_text EP path", "27% of input_scale values differed … by up to 28%", "the validated production path byte-for-byte unchanged". They do explain why, which helps. But CONTRIBUTING.md asks for one or two lines per comment, and puts benchmark numbers and root-cause analysis in the PR description. The longest are the combine comment (lines 208-219) and the count-exchange comment (148-153).
The combine added each routed result into an fp32 [tokens, hidden] accumulator with index_add_, which adds with atomics on CUDA. With fp32 routing weights (Mixtral's, DeepSeek-V3's) the order of a token's adds, and with it the bf16 result, changed from run to run. The results now come back in (token, slot) order, a -1 slot's row zero, and each token's top_k rows are weighted and summed with view(-1, top_k, H).sum(1), the reduction transformers' grouped_mm forward uses: the same result every run and at any EP degree. A slice of tokens at a time bounds the temporaries, and the fp32 accumulator is gone. A rank without tokens still runs one empty slice, which keeps its result on the combine's graph. A GPU test checks grouped_mm's result bit for bit on CUDA. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The a2a warned when a rank received over 4x what it sent itself. A rank receives about the average share, so a rank with a small batch warned whenever the batches differed, balanced routing or not. It now compares with the most tokens any rank sent, which rides on the all-reduce that already sizes the hops, and the warning leaves the counts out of its text, so Python's filter shows it once rather than for every layer and batch. The comment citing a trace a2a_moe no longer has goes with it. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The torch_a2a experts forward indexes the experts' biases by local expert id, assuming the plan sharded them over dim 0 like the weights. GPT-OSS's stock plan does, but a plan or spec that lists only the weights leaves them whole, and every rank but the first then adds other experts' biases without a word. The forward now refuses biases that do not have one row per local expert. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
… arrives The transport's docstrings and comments said ModelOpt quantizes the local experts through _grouped_linear. On main ModelOpt hooks only the eager experts path, and quantize() even switches a model to eager; the _grouped_linear override and the torch_a2a exception come with the fused-experts PR. Until then the docs say what this PR does: the experts run through transformers' _grouped_linear on the tokens dispatched to them from every rank. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Several comments read like incident notes: capitals for emphasis, the model and batch an overflow was observed on, how long it hung, which debug setting hid a race. CONTRIBUTING.md asks for a line or two per comment; each now says why its code is there, and the numbers live in the PR description. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The test simulates 5.16.0, which exports the router style only as EpRouterParallel, by deleting RouterParallel; on 5.16.0 there is none to delete, and monkeypatch.delattr raised instead of letting the test check the fallback. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Each job made a new gloo group on the session's two persistent cpu_dist_workers and left it, with its backend, registered until the pool shut down. The pool asks jobs to undo the global state they set, as test_hf_checkpoint_utils_dist.py does with its group; these jobs now destroy theirs too. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The pooled workers now reseed after every job, so both ranks of _test_data_parallel_auto_quantize drew the same batches from model.get_input(), and its check that every rank ends with the same costs and scores passed with the data-parallel score sync removed. Its batches are now seeded by rank, after the model is built from the shared seed; without the sync the test fails. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
|
/ok to test 858dd1b |
|
@sychen52, on the two points in your review body:
|
What does this PR do?
Type of change: New feature
First of 21 PRs that add expert-parallel (EP) PTQ for Hugging Face MoE models to
examples/hf_ptqundertorchrun. They merge in order:grouped_mm([3/21]).DistributionPlandecided once per run, andload_pretrainedloading by it, replicated, FSDP2-sharded, or with an MoE's experts sharded. After [12/21], an MoE whose expert-parallel plan ships with transformers loads and calibrates expert-parallel.hf_ptq.py.This PR adds the transport.
Calibrating a large MoE has no good option today. Replicating it needs the whole model on every rank. Sharding it with FSDP2 alone gathers every expert on every rank for each forward, so the expert weights dominate both memory and time, while each rank still only sees its own slice of tokens.
DP=EP instead: dense layers and the router run data-parallel, the routed experts are sharded across ranks, and tokens go to the rank that owns their expert by an all-to-all dispatch and come back by the combine. Each expert sees every token routed to it from all ranks, so its amax is collected over the global token pool rather than one rank's share. That is the accuracy point, not just a memory one. The expert matmul stays on transformers' own
_grouped_linear, where [3/21] attaches ModelOpt's per-expert quantizers as in a single process.register_torch_a2a()installs the transport as a transformers experts implementation, lazily rather than at import.The transport is plain
all_to_all_singleover NCCL, so it works across nodes at any rank count. Three things it has to get right:grouped_mmdoes: a router that returns fp32 weights (Mixtral's, DeepSeek-V3's) gets an fp32 product. The weights never travel, so no hop carries them, and their gradient comes from the local product.index_add_uses on CUDA, rounds differently from one EP degree or run to the next, so activation scales would shift and a checkpoint would not reproduce. The results come back in (token, slot) order, and each token's top-k rows are weighted and summed withview(n, top_k, H).sum(1), as transformers'grouped_mmdoes, a slice of tokens at a time. Each transport buffer is freed as soon as its stage is done: a whole fp32 copy of the results, beside buffers kept to the end, reached GiBs at calibration batch sizes.all_to_all_singleaddresses the flattened buffer with element offsets, which overflow int32 once rows × hidden exceeds 2**31. The transport chunks along the hidden dimension, into slices as wide as the most rows any rank moves allows. Ranks agree on that row count with one all-reduce per layer; deciding it from local sizes desyncs the hop count and deadlocks.A rank that receives more than 4x the most tokens any rank sent (a collapsed router piling the cluster's tokens onto a few experts) warns once, before the expert stage allocates for that count. A rank can receive at most world size times that, so the warning fires only above 4 ranks.
The incidents behind the transport's less obvious code:
all_to_all_single's int32 element offsets, for an extra hop or two past 2**31. Added after a hang in EP calibration that has not been reproduced since: with the slicing disabled, such hops complete exactly on NCCL 2.29, 2.30 and 2.31, including at 32 ranks over the network on 2.29 and 2.30Usage
Internal to the expert-parallel loader ([9/21]); nothing to call directly.
Testing
tests/unit/torch/utils/test_moe_a2a.py, under gloo on CPU:torch_a2aforward they give the whole module's output and gradients, for the hidden states, the routing weights and each rank's own experts (mutation-checked);torch_a2aforward givesgrouped_mm's result bit for bit (fails on the previous revision, which rounded the weights to bf16);[tokens, hidden]accumulator exceeds the slice);-1or no tokens at all, but serves its peer's tokens: its backward still runs the reverse all-to-all, so both ranks get their input gradients (the test's group times out in 10 s, so a regression fails instead of hanging);tests/gpu/torch/utils/test_moe_a2a.py: with fp32 routing weights, at EP=1 thetorch_a2aforward givesgrouped_mm's result bit for bit on CUDA. Run on GB200, against the previous revision'sindex_add_combine and this one (elements that differ fromgrouped_mm, of 4,194,304):index_add_)On a real model, run to run: two identical calibrations of Qwen3-30B-A3B in FP8 (
fp8_default-kv_fp8), EP4 on 4 GB200 with the dense layers replicated, batch size 2, compared scale by scale (the previous combine's pair ran at two revisions that both had it, on transformers 5.13; this revision's on 5.9):index_add_)Peak memory of one layer's transport, on CPU, at 4096 tokens, hidden 4096, top-k 8, fp32 routing weights:
The two-rank tests run on
cpu_dist_workers, the two gloo ranks main's unit tests keep up for the session (tests/unit/conftest.py), since spawning ranks cost each test about 14 s of the linux unit job. This PR makes those workers reseed after every job, so each job starts from the seed a freshly spawned rank gets; a job that draws a different amount of randomness on each rank otherwise leaves the next job's ranks out of step. Since both ranks then draw the same data, main'stest_data_parallelnow seeds its calibration data by rank: its group-MAX check would otherwise hold with or without the data-parallel amax sync. It fails when that sync is removed.test_autoquant's data-parallel test seeds its batches by rank for the same reason, and fails when the score sync is removed.The result keeps the dtype it is given: fp32 and fp16 experts through the transport, in a one-rank group, match a dense reference in their own dtype. Tokens travel in the model's dtype.
The test of a hand-written expert function against a dense reference is dropped: the Qwen3-MoE experts test checks the same forward and gradients, and the dtype test runs a one-rank group. The drop is checked by mutation.
Locally on this revision, the series on main 6cd9ced: pre-commit (every hook, mypy included) over each PR of the stack against main; the changed tests in this PR and in [2/21], [3/21] and [8/21], and at the top of the stack the unit test files that run this transport, on transformers 5.18 and 5.5. CI runs the full unit lanes and the docs build.
The GPU results here were measured on earlier revisions of this branch; this revision's GPU tests run in CI only.
On GPUs:
tests/gpu/torch/utils/test_expert_parallel_load.py([9/21]) runs a tiny Qwen3-MoE through this transport on every GPU and matches the unsharded model's logits; the series' model runs exercise it at 4 to 64 ranks (see [21/21]).Before your PR is "Ready for review"
CONTRIBUTING.md: N/AAdditional Information
Part 1 of 21 of expert-parallel PTQ for Hugging Face MoE models. #2733 has merged, so this PR is based on main and merges first; the series then merges in order, [1/21] through [21/21].
Summary by CodeRabbit
New Features
Tests