Skip to content

perf(moe): sync-free HybridEP dispatch (capacity mode, equal token counts) - #3931

Open
yisongbetter wants to merge 6 commits into
NVIDIA-NeMo:mainfrom
yisongbetter:yisongbetter/perf/hybridep-syncfree-dispatch
Open

yisongbetter wants to merge 6 commits into
NVIDIA-NeMo:mainfrom
yisongbetter:yisongbetter/perf/hybridep-syncfree-dispatch

Conversation

@yisongbetter

@yisongbetter yisongbetter commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

What does this PR do ?

Removes the two per-dispatch host synchronisations on the HybridEP MoE path under dynamic routing, both as opt-in BackendConfig knobs 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 takes num_permuted_tokens from 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_async on 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 an int() 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) and dispatcher_equal_token_counts (bool, default False), with docstrings.
  • nemo_automodel/components/moe/experts.py: GroupedExpertsDeepEP reads both knobs from the backend, forwards them to TokenDispatcherConfig, and skips its per-microbatch count_nonzero host read in capacity mode (rows are never empty). Rejects dispatcher_capacity_factor for expert_bias experts at construction: the bias add sizes its repeat_interleave from the padded buffer rows, not the routed count.
  • nemo_automodel/components/moe/megatron/token_dispatcher.py: TokenDispatcherConfig.moe_hybridep_capacity_factor / moe_hybridep_equal_token_counts; _HybridEPManager calibration (_calibrate_hybridep_capacity), non-blocking dispatch with the capacity, _assert_no_hybridep_overflow, and the equal-count pad size. The overflow guard reads the flag from the handle's last slot (handle[-1]), which DeepEP keeps stable across the previous 11-item layout and the 12-item layout of the revision main pins since fix(deps): update DeepEP for Rubin C++20 builds #3973 (num_of_valid_tokens now sits at slot 10).
  • nemo_automodel/components/moe/megatron/fused_a2a.py: HybridEPCombine.backward passes non_blocking=True when the count is a host int; HybridEPDispatchReplayRecorder.record keeps 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, and finalize reduces tokens_per_expert only for blocking dispatches.
  • Tests: 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 reducing tokens_per_expert; a checkpointed dispatch with a capacity extent replays with that extent). tests/unit_tests/moe/test_experts.py: both knobs reach GroupedExpertsDeepEP, default off without a backend, and the capacity factor is rejected with expert_bias.

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_expert and the combine round trip are torch.equal to 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 _HybridEPManager with 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 %):

config tok/s/GPU step s vs control
control (blocking dispatch, per-dispatch pad all-reduce) 1957.9 1.046
dispatcher_capacity_factor: 1.5 + dispatcher_equal_token_counts: true 2124.0 0.964 +8.5 %
control repeated (A/A) 1959.5 1.045 +0.08 %
reference: benchmark_static_routing: true (the existing pins) 2089.2 0.980 +6.7 %

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_experts.py, tests/unit_tests/moe/test_static_routing_m_splits.py — 213 passed in 35.9 s on one GB200 with a cold Triton cache (job 3144590, this branch on main 9dd6108, tree 9a50a7f44f47).

Before your PR is "Ready for review"

Pre checks:

  • Make sure you read and followed Contributor guidelines
  • Did you write any new necessary tests? (calibration then capacity passed as a host int with non-blocking dispatch; the calibration dispatch keeps its exact count; overflow flag trips the guard; static routing and the default ignore the factor; equal counts skip the all-reduce and align, default keeps the per-dispatch all-reduce; the replay recorder keeps a host extent and the checkpoint recompute redispatches to it)
  • Did you add or update any necessary documentation? (BackendConfig docstrings for both knobs; comments at the dispatch call explaining the two syncs)

Additional Information

@yisongbetter
yisongbetter requested a review from a team as a code owner September 17, 2026 17:54
@yisongbetter

Copy link
Copy Markdown
Contributor Author

/ok to test 52fb62e

@copy-pr-bot

copy-pr-bot Bot commented Sep 17, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@yisongbetter

Copy link
Copy Markdown
Contributor Author

/ok to test c0cf685

…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>
In capacity mode the dispatched buffer holds the padded capacity rows while
tokens_per_expert still sums to the routed rows. _apply_bias sizes its
repeat_interleave from the buffer rows (output_size=flat_value.shape[0]), so
the bias add would trip the CUDA kernel's size assert. No shipped recipe
combines the two (Kimi K3 has no expert bias); fail at construction instead
of on the first microbatch, and say so in the BackendConfig docstring.

Tests: GroupedExpertsDeepEP reads both knobs from the backend, defaults them
off without one, and rejects the capacity factor when expert_bias is set.

Signed-off-by: Yisong Li <yisongbetter@gmail.com>
…tests

The two TokenDispatcherConfig fields had been inserted between
moe_flex_dispatcher_backend and its attribute docstring; give each field its
own docstring in the file's style. Add Args (shapes, host vs device) to
_assert_no_hybridep_overflow, _calibrate_hybridep_capacity and
HybridEPDispatchReplayRecorder.record. Fold the two BackendConfig knob tests
into a class and drop their redundant local imports.

Signed-off-by: Yisong Li <yisongbetter@gmail.com>
DeepEP 10d4dd7 (main's pin since NVIDIA-NeMo#3973) returns a 12-item dispatch handle:
num_of_valid_tokens (a Python int) moved into slot 10 and overflow_flag is
now slot 11, kept last by DeepEP so callers use handle[-1]. The guard was
written against the previous 11-item layout and read slot 10, so on the
current pin it saw an int, skipped the tensor check and never asserted.

Read handle[-1], which is the flag on both layouts, and make the test's fake
handle use the 12-item layout so the old index fails the overflow test.

Signed-off-by: Yisong Li <yisongbetter@gmail.com>
@yisongbetter
yisongbetter force-pushed the yisongbetter/perf/hybridep-syncfree-dispatch branch from 9bbdbf4 to 616dae9 Compare September 23, 2026 19:23
@yisongbetter

Copy link
Copy Markdown
Contributor Author

/ok to test 616dae9

This branch was successfully deployed

3 active deployments
public 616dae90 Deployed Sep 23, 2026 by copy-pr-bot[bot] via release / finalize / notify #4811
test 616dae90 Deployed Sep 23, 2026 by copy-pr-bot[bot] via cicd-wait-in-queue #10984
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants