perf(moe): sync-free HybridEP dispatch (capacity mode, equal token counts) - #3931
Open
yisongbetter wants to merge 3 commits into
Open
yisongbetter wants to merge 3 commits into
yisongbetter wants to merge 3 commits into
Conversation
Contributor
Author
|
/ok to test 52fb62e |
…ic routing HybridEP's blocking dispatch learns the permuted row count by draining the compute stream (`torch.cuda.current_stream().synchronize()` after metadata preprocessing) on every MoE layer, forward and activation-checkpoint recompute: the CPU cannot run ahead of the GPU and every layer becomes an EP-group barrier. Its non-blocking mode takes `num_permuted_tokens` from the caller and only uses it to size the output buffers; the real counts stay on the device and the handle carries an overflow flag (verified bit-exact on GB200 for capacities of 1x / 1.5x / 2x the actual count). Opt-in `BackendConfig.dispatcher_capacity_factor` (default None = the blocking path): the first dispatch of a layer runs blocking and calibrates capacity = ceil(rows x factor), 4-token aligned, EP-group max; every later dispatch passes that capacity and runs non-blocking, the combine and its backward dispatch take the same host int (no drain there either), and `torch._assert_async` on HybridEP's overflow flag fails loudly instead of training on truncated tokens. GroupedExpertsDeepEP skips its per-microbatch count_nonzero host read in this mode (rows are never empty). Ignored under benchmark_static_routing, which already pins the exact count. Measured on 8 x GB200 (EP16, dynamic routing, Kimi-K3 16-layer twin) together with the equal-token- count pad change of the next commit: 1957.9 -> 2124.0 tok/s/GPU (+8.5%, A/A band 0.08%), loss curves identical; forward output and input gradient bit-identical to the blocking path in a 4-rank end-to-end check. Tests: tests/unit_tests/moe/test_token_dispatcher.py (calibration, capacity passed non-blocking, overflow guard, static routing / no factor unchanged), tests/unit_tests/moe/test_backend_config.py. Signed-off-by: Yisong Li <yisongbetter@gmail.com>
…all-reduce The padding added in NVIDIA-NeMo#3641 derives every rank's HybridEP dispatch size from an EP-group MAX all-reduce of the local row count followed by an int() host sync, once per MoE layer per forward and per recompute. The operand is a host-known shape: for fixed-shape batches — every batch that is not variable-length or in-batch packed — all ranks hold the same count and the collective only costs a compute-stream drain and a per-layer barrier (static routing already pins it, NVIDIA-NeMo#3895). Opt-in `BackendConfig.dispatcher_equal_token_counts` (default False) declares equal counts: the pad size becomes the aligned local count with no collective. Keep it False for variable-length inputs, where unequal counts would abort the HybridEP collective. Measured on 8 x GB200 under static routing (the collective isolated): skipping it is +7.4% at that scale, of which 5.8 points are the host sync and 1.6 the barrier; at 64 nodes the same pin measured +0.65%. On the dynamic-routing path it is worth combining with dispatcher_capacity_factor (previous commit), which removes the other per-layer host sync. Tests: tests/unit_tests/moe/test_token_dispatcher.py (no all-reduce and aligned sizes when set; per-dispatch all-reduce when unset), tests/unit_tests/moe/test_backend_config.py. Signed-off-by: Yisong Li <yisongbetter@gmail.com>
NVIDIA-NeMo#3684 replays the checkpoint-forward HybridEP layout on recompute and sizes the replayed dispatch to `int(tokens_per_expert.sum().item())`. Under capacity mode the forward output is sized to the capacity, not to this dispatch's token count, so the replay must reuse the same integer or the recomputed activation no longer matches the saved one. When the forward already ran with a host-side extent (capacity mode, static-routing pin), record that integer; `finalize` then only reduces the entries of blocking dispatches, so capacity mode keeps its forward free of the per-layer device-to-host copy that the reduction would add back. Tests: tests/unit_tests/moe/test_fused_a2a.py — a recorded host extent is kept without reducing tokens_per_expert; a checkpointed dispatch with a capacity extent replays with that extent. Signed-off-by: Yisong Li <yisongbetter@gmail.com>
yisongbetter
force-pushed
the
yisongbetter/perf/hybridep-syncfree-dispatch
branch
from
September 18, 2026 09:11
52fb62e to
c0cf685
Compare
Contributor
Author
|
/ok to test c0cf685 |
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.
What does this PR do ?
Removes the two per-dispatch host synchronisations on the HybridEP MoE path under dynamic routing, both as opt-in
BackendConfigknobs that default to today's behaviour. (1)dispatcher_capacity_factor: HybridEP's blocking dispatch learns the permuted row count by draining the compute stream (torch.cuda.current_stream().synchronize()after metadata preprocessing) on every MoE layer, forward and activation-checkpoint recompute; its non-blocking mode takesnum_permuted_tokensfrom the caller and only uses it to size the output buffers, with the real counts left on the device and an overflow flag in the handle. The knob runs one blocking calibration dispatch per layer, sizes every later dispatch to ceil(rows × factor) (EP-group max, 4-token aligned) and runs non-blocking;torch._assert_asyncon the overflow flag fails loudly instead of training on truncated tokens. The combine's backward dispatch takes the same host int, so it needs no drain either. (2)dispatcher_equal_token_counts: the #3641 pad size is an EP-group MAX all-reduce of a host-known shape plus anint()host sync; for fixed-shape batches every rank holds the same count, so the knob replaces the collective with the aligned local count (keep it off for variable-length or in-batch-packed inputs). With both on, the CPU runs ahead of the GPU through the whole MoE layer and no layer acts as an EP-group barrier.Changelog
nemo_automodel/components/models/common/utils.py:BackendConfig.dispatcher_capacity_factor(float | None, default None) anddispatcher_equal_token_counts(bool, default False), with docstrings.nemo_automodel/components/moe/experts.py:GroupedExpertsDeepEPreads both knobs from the backend, forwards them toTokenDispatcherConfig, and skips its per-microbatchcount_nonzerohost read in capacity mode (rows are never empty).nemo_automodel/components/moe/megatron/token_dispatcher.py:TokenDispatcherConfig.moe_hybridep_capacity_factor/moe_hybridep_equal_token_counts;_HybridEPManagercalibration (_calibrate_hybridep_capacity), non-blocking dispatch with the capacity,_assert_no_hybridep_overflow, and the equal-count pad size.nemo_automodel/components/moe/megatron/fused_a2a.py:HybridEPCombine.backwardpassesnon_blocking=Truewhen the count is a host int;HybridEPDispatchReplayRecorder.recordkeeps a host-side extent (capacity mode, static-routing pin) so perf(moe): reuse the DeepEP dispatch layout on activation-checkpoint recompute #3684's activation-checkpoint replay redispatches to the same extent the forward was sized to, andfinalizereducestokens_per_expertonly for blocking dispatches.tests/unit_tests/moe/test_token_dispatcher.py(calibration → capacity passed non-blocking, exact count kept for the calibration dispatch's combine, overflow guard, static routing / no factor unchanged; equal counts skip the all-reduce and align, default keeps it),tests/unit_tests/moe/test_backend_config.py(defaults);tests/unit_tests/moe/test_fused_a2a.py(a recorded host extent is kept without reducingtokens_per_expert; a checkpointed dispatch with a capacity extent replays with that extent).Validation
HybridEP API check (one GB200 node, EP4, 32 experts top-8, random dynamic routing, 8192 rows × 7168): with capacity 1×, 1.5× and 2× the actual count, the dispatched rows, probs, device-side
tokens_per_expertand the combine round trip aretorch.equalto the blocking path on all ranks, overflow flag 0; with capacity = actual/2 the flag is set (silent truncation, no fault) — hence the device-side assert. An end-to-end check through_HybridEPManagerwith autograd, 12 dynamic-routing steps, gave forward outputs and input gradients bit-identical to blocking on all 4 ranks; the dispatch call's host time fell from 1.30 ms to 0.21 ms (the blocking value is bounded by whatever GPU work is queued ahead — in training that is the whole backlog).Throughput (8 × GB200 NVL72, EP16 × PP2, Kimi-K3 16-layer twin, 2k-token rows, mock data,
benchmark_static_routing: false= dynamic routing, same job, A/A band 0.08 %):dispatcher_capacity_factor: 1.5+dispatcher_equal_token_counts: truebenchmark_static_routing: true(the existing pins)Loss curves identical (last-step 12.4403–12.4404 across the four rungs); one calibration per layer (33152 rows × 1.5 → capacity 49728), zero overflows. Isolating the pad collective under static routing at the same scale: +7.4 % (5.8 points from the host sync, 1.6 from the barrier); a side-stream variant of the same collective on the dynamic path recovers nothing (+0.29 %) because the blocking dispatch still drains the stream — which is why both knobs ship together. The 64-node measurement of the dynamic-routing stack will be added here.
Tests:
tests/unit_tests/moe/test_token_dispatcher.py,tests/unit_tests/moe/test_fused_a2a.py,tests/unit_tests/moe/test_backend_config.py,tests/unit_tests/moe/test_static_routing_m_splits.py— 102 passed in 6.5 s on one GB200 with a cold Triton cache (job 3090506, this branch onmain28a79d0, tree e48796d1300c).Before your PR is "Ready for review"
Pre checks:
BackendConfigdocstrings for both knobs; comments at the dispatch call explaining the two syncs)Additional Information
ignore_router_for_acthe recorder replays the forward's HybridEP layout and sizes the redispatch to a host integer. In capacity mode the forward output iscapacityrows, so the recorder now records that integer instead ofint(tokens_per_expert.sum().item()); without this the recompute would produce the actual row count and fail the checkpoint metadata check. Recording the known integer also keeps capacity mode free of the per-layer device-to-host copy that the reduction adds for blocking dispatches. Static-routing pins (a host int since fix(moe): equalize and align per-rank token counts for HybridEP dispatch #3641) get the same treatment.factor ×the calibrated rows per live MoE layer (≈ +0.5 GiB per layer at the mini shape, ≈ +0.9 GiB at 64 nodes with factor 1.5).dispatcher_equal_token_countsis the "just remove the all-gather" option discussed for fix(moe): equalize and align per-rank token counts for HybridEP dispatch #3641's padding: it is safe exactly when every rank's row count is equal, which the caller knows and HybridEP does not, hence a declaration rather than a default. Variable-length inputs must keep the all-reduce (or pin a static cap).