Skip to content

perf(moe): reuse the DeepEP dispatch layout on activation-checkpoint recompute - #3684

Draft
akoumpa wants to merge 3 commits into
mainfrom
akoumparouli/moe-hybridep-ac-warning
Draft

perf(moe): reuse the DeepEP dispatch layout on activation-checkpoint recompute#3684
akoumpa wants to merge 3 commits into
mainfrom
akoumparouli/moe-hybridep-ac-warning

Conversation

@akoumpa

@akoumpa akoumpa commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Two shared-MoE changes found while profiling DiffusionGemma-26B-A4B on 8×H100. Neither is dLLM-specific — both apply to any MoE model using expert parallelism with activation checkpointing.


1. Reuse the DeepEP dispatch layout on recompute (the optimization)

Activation checkpointing replays a block's forward during backward, and the replayed MoE dispatch recomputes its routing layout from scratch — get_dispatch_layout() plus DeepEP's notify_dispatch — even though the routing is identical to the forward's. The AC policy pins the router projection and top-k precisely so recompute routes the same way, so recomputing the layout from that identical routing is redundant.

DeepEP already exposes the shortcut: handing dispatch() the handle from a previous dispatch skips the layout exchange. FusedDispatch.backward already does exactly this, and the profile shows what it's worth. Per step, rank 0:

launches GPU time
forward dispatch (computes layout) 150 4420 ms
backward dispatch (reuses ctx.handle) 60 3.96 ms
notify_dispatch (forward) 150 4844 ms
cached_notify_dispatch 60 797 ms

Of those 150 forward dispatches the model only needs 90 (30 layers × 3 passes: causal encoder, no_grad self-conditioning decode, real decode). The other 60 are activation checkpointing replaying the encoder and decode-2 passes.

How

A recorder is created per checkpointed call, so the forward logs each dispatch's handle and routing metadata in call order and the recompute consumes them in the same order — a block called once per pass keeps its passes separate, which matters here because the three passes route differently.

Cached-mode dispatch() returns only recv_x (recv_topk_idx, recv_topk_weights and num_recv_tokens_per_expert_list all come back None), so the routing metadata comes from the record. recv_x itself is deliberately not retained, so the activation memory checkpointing saves is preserved — only the handle and per-token routing metadata are held, ~1.7% of the dispatched activation. If a replay ever outruns its log, the recorder falls back to a full dispatch rather than replaying a mismatched layout.

Scoped to the ignore_router branch on purpose: that is the path that pins routing across recompute. On the other branch the router is recomputed and may route differently, where a replayed layout would silently mis-route instead of failing loudly.

Measured

eos, 8×H100, 8 steps, DiffusionGemma-26B-A4B SFT with dispatcher=deepep:

step time
before 10.6 s
after 9.77 s (−7.8%)

Loss, dllm_loss and grad_norm are bit-identical to the unpatched run for all 8 steps — matching grad_norm means the backward produces identical gradients, which is the correctness bar for this change.

Two DeepEP knobs were measured on the same workload and make no difference, so they are not used: dispatcher_async_dispatch=True (10.66 s/step) and dispatcher_num_sms=32 (10.61 s/step).


Note (not fixed here)

The torch.ops.deepep.dispatch / hybridep.dispatch entries in activation_checkpointing.py's selective-AC save list are inert: dispatch runs through FusedDispatch.apply, an autograd.Function, which a __torch_dispatch__ policy never observes. Those four entries have never matched anything. Worth a separate fix.

The dLLM recipe change that motivated this profiling is #3654 (recipe YAMLs only).

@akoumpa
akoumpa requested a review from a team as a code owner August 26, 2026 06:48
@copy-pr-bot

copy-pr-bot Bot commented Aug 26, 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.

@akoumpa akoumpa changed the title fix(moe): warn when HybridEP dispatch is used with activation checkpointing perf(moe): reuse the DeepEP dispatch layout on activation-checkpoint recompute Aug 26, 2026
…ation checkpointing

Combining dispatcher="hybridep" with activation checkpointing dies in the first
backward with an opaque error:

  CheckpointError: Recomputed values for the following tensors have different
  metadata than during the forward pass.
    saved:      torch.Size([2791, 2816])
    recomputed: torch.Size([2701, 2816])

The message points at the checkpoint machinery, which sends readers to the
router: the documented cause of a shape change like this is non-deterministic
re-routing, and ignore_router_for_ac exists to prevent it. That is not what is
happening here.

Instrumenting Gemma4Gate on 8xH100 with DiffusionGemma-26B-A4B shows the
routing is reproduced exactly. Comparing the encoder pass against its recompute,
the top-k index checksum matches on every rank:

  rank 0: fwd 282623 == rec 282623      rank 4: fwd 425381 == rec 425381
  rank 1: fwd 275769 == rec 275769      rank 5: fwd 279545 == rec 279545
  rank 2: fwd 276947 == rec 276947      rank 6: fwd 278018 == rec 278018
  rank 3: fwd 273901 == rec 273901      rank 7: fwd 262048 == rec 262048

and all 30 decoder layers match as well (0/30 mismatches per rank). Despite
identical routing on every rank, hybrid_ep_dispatch returned 2791 rows in the
forward and 2701 on replay. The drift is inside the dispatch, so no router-side
mitigation can prevent it; per-layer dispatch managers
(dispatcher_share_token_dispatcher=False) do not help either, and pad_multiple
is set once at construction and never reassigned.

DeepEP's dispatch replays reproducibly and is unaffected.

Warn rather than raise: the failure depends on whether replayed token counts
happen to drift for a given model and shape, and other recipes run HybridEP with
activation checkpointing today. The warning names both working configurations --
dispatcher="deepep" with checkpointing, or HybridEP with checkpointing disabled
(measured the fastest of the two: 5.46 s/step vs 5.83 s/step on 8xH100).

Signed-off-by: Alexandros Koumparoulis <akoumparouli@nvidia.com>
…recompute

Activation checkpointing replays a block's forward during backward, and the
replayed MoE dispatch recomputes its routing layout from scratch --
get_dispatch_layout() plus DeepEP's notify_dispatch -- even though the routing
is identical to the forward's. The AC policy pins the router projection and
top-k precisely so that recompute routes the same way, so recomputing the
layout from that identical routing is redundant work.

DeepEP already exposes the shortcut: handing dispatch() the handle from a
previous dispatch skips the layout exchange. FusedDispatch.backward already
does this, and the profile shows what it is worth -- on 8xH100 with
DiffusionGemma-26B-A4B, per step:

  forward dispatch (computes layout)   150 launches   4420 ms
  backward dispatch (reuses handle)     60 launches      3.96 ms
  notify_dispatch (forward)            150 launches   4844 ms
  cached_notify_dispatch                60 launches    797 ms

Of the 150 forward dispatches the model only needs 90 (30 layers x 3 passes:
causal encoder, no_grad self-conditioning decode, real decode). The other 60
are activation checkpointing replaying the encoder and decode-2 passes.

A recorder is created per checkpointed call, so the forward logs each
dispatch's handle and routing metadata in call order and the recompute consumes
them in the same order -- a block called once per pass keeps its passes
separate. Cached-mode dispatch returns only recv_x, so the routing metadata
comes from the record; recv_x itself is deliberately not retained, so the
activation memory that checkpointing saves is preserved (only the handle and
the per-token routing metadata are held, ~1.7% of the dispatched activation).
If a replay ever outruns its log the recorder falls back to a full dispatch
rather than replaying a mismatched layout.

Scoped to the ignore_router branch on purpose: that is the path that pins
routing across recompute. On the non-ignore_router path the router is
recomputed and may route differently, where a replayed layout would silently
mis-route instead of failing loudly.

Measured on eos, 8xH100, 8 steps, DiffusionGemma-26B-A4B SFT with
dispatcher=deepep:

  before   10.6 s/step
  after     9.77 s/step   (-7.8%)

Loss, dllm_loss and grad_norm are bit-identical to the unpatched run for all 8
steps, as expected for a change that only avoids recomputing a layout that was
already determined by the (pinned) routing.

Two DeepEP configuration knobs were measured on the same workload and made no
difference, so they are not used here: dispatcher_async_dispatch=True
(10.66 s/step) and dispatcher_num_sms=32 (10.61 s/step).

Signed-off-by: Alexandros Koumparoulis <akoumparouli@nvidia.com>
@akoumpa
akoumpa force-pushed the akoumparouli/moe-hybridep-ac-warning branch from e2ae813 to 6644561 Compare August 26, 2026 06:57
@akoumpa
akoumpa marked this pull request as draft August 26, 2026 07:10
@akoumpa

akoumpa commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

/ok to test 6644561

@akoumpa

akoumpa commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Re-ran the benchmark against the exact code in this PR (the numbers in the description came from a functionally-equivalent earlier form of the context manager, so this closes that gap):

step time   9.74 s   (baseline 10.6 s, -8.1%)
TORCHRUN_EXIT=0

Loss, dllm_loss and grad_norm bit-identical to the unpatched deepep run for all 8 steps:

step loss dllm_loss grad_norm
0 4.9583 1.3249 235.2967
1 5.8585 2.0045 242.9313
2 3.4129 1.4974 123.3242
3 3.2504 1.8378 55.8912
4 4.5354 3.7083 148.1208
5 4.1949 1.7752 254.6327
6 2.7769 1.9149 32.7600
7 1.7619 0.9086 11.5002

Matching grad_norm is the meaningful part: the backward produces identical gradients, not just a similar-looking loss curve.

The HybridEP activation-checkpointing warning added in this PR walked the
model with model.modules(), which made apply_ac require more of its argument
than the checkpointing itself does and broke all 19 apply_ac unit tests
("AttributeError: 'DummyModel' object has no attribute 'modules'"). The walk
only gates a warning, so skip it when the model does not expose modules().

Also fix two test doubles that the new code exposed:

- create_selective_checkpoint_contexts stubs returned a bare "CTX" sentinel,
  but the real torch API returns (forward_ctx, recompute_ctx) and the block
  context wrappers unpack it. Return a pair instead.
- fused_a2a is imported lazily, so test_parallelizer only passed when some
  earlier test had already imported it under the real torch. Stub it in
  _import_parallelizer_with_stubs so the module stands alone.

Signed-off-by: Alexandros Koumparoulis <akoumparouli@nvidia.com>
@akoumpa

akoumpa commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

/ok to test ee94057

@akoumpa

akoumpa commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

nemo-ci validation on AC + EP recipes: no regression found

Ran this branch against 10 nemo-ci recipes that combine activation checkpointing with expert parallelism, paired against a matched baseline at this PR's exact merge-base (338f57f), same test set, same cluster (eos / x86, scope=release):

The set was chosen to cover the path this PR actually changes (dispatcher: deepep + AC + ignore_router=True, which is the default) across all three expert backends, plus PP/CP, PEFT, THD packing, and a HybridEP control. It is weighted toward recipes with recent failure history in CI.

Result: 8/10 pass on both arms; the 2 failures reproduce identically on the baseline

test config PR base per-step loss vs base
qwen3_moe_30b_te_packed_sequence deepep ep8, te experts, THD 1024 pass pass identical, 51/51
ling_mini_2_0_sft deepep ep8, torch_mm pass pass identical, 51/51
qwen3_moe_30b_te_hybridep hybridep ep8, te experts pass pass identical, 51/51
qwen3_moe_30b_te_deepep deepep ep8, torch_mm pass pass 9/51 differ, max 4.5e-2
qwen3_moe_30b_hellaswag deepep ep8, torch_mm pass pass 9/51 differ, max 1.2e-2
qwen3_moe_30b_lora deepep ep8, PEFT pass pass 9/51 differ, max 7.4e-3
ernie4_5_21b_a3b_hellaswag deepep ep8, torch_mm pass pass 51/51 differ, max 2.2e-2
qwen3_moe_30b_te_chat_thd deepep ep4 / pp2 / cp2 pass pass 47/51 differ, max 1.9e-3
glm_4.7_flash_te_deepep deepep ep8, gmm fail fail training identical
hy3_preview_deepep deepep ep8 / pp8, 8-node fail fail n/a

No CheckpointError in any job on either arm. (The string appears once in the PR run — inside this PR's own warning text.)

Why the non-identical rows are not attributable to this PR

Two independent controls, since raw loss deltas alone can't separate a real change from recipe noise:

  1. Step 0 is the pure forward, before any recompute can run. For qwen3_moe_30b_te_deepep, qwen3_moe_30b_hellaswag, qwen3_moe_30b_lora and ernie4_5_21b_a3b_hellaswag, step 0 already differs between the two arms (e.g. ernie 3.0304 vs 3.0302). The replay only runs in the backward recompute, so it cannot produce a step-0 difference — those recipes are nondeterministic run to run.

  2. qwen3_moe_30b_te_chat_thd matches at step 0 and diverges afterwards, which is exactly where a gradient-path change would appear, so it needed its own control. I re-ran the baseline against itself (same commit, second run — pipeline 64778774):

    comparison mismatches max abs delta
    base vs. base (same commit, two runs) 48/51 0.0028
    PR vs. base 47/51 0.0019

    Two runs of the identical baseline commit diverge by more than the PR does, so this recipe (cp2 + pp2 + THD) is intrinsically nondeterministic and the PR's delta sits inside its own noise floor. The same control run reproduced qwen3_moe_30b_te_packed_sequence bit-identically, which is what makes this trustworthy: the method is sensitive enough to detect a real change, and detects none.

The two failures

  • glm_4.7_flash_te_deepep — training completes 8/8 steps with a bit-identical loss curve on both arms (final 2.7334). The failure is the checkpoint-robustness source_load parity assert, and both arms report the same value to 7 significant figures: mean KL 2.908613e-01, cosine 0.97474841. The same figure appears in historical runs (jobs 407192201, 402287636, 402052247), so this is a deterministic pre-existing failure in the HF-reload parity check, untouched by this PR.
  • hy3_preview_deepep — fails on both arms (Timeout on the PR arm, Infra Issue on the baseline arm, which never reached training). Its last 7 CI failures were all timeouts.

HybridEP warning

Fires exactly once, only on the hybridep recipe, and that job still passes — warn-not-raise behaves as intended. Worth noting the guard is narrower than the warning text implies: hybridep dispatches through HybridEPDispatch, a separate autograd.Function, so it never reaches the recorded FusedDispatch path at all — the replay is genuinely inert there rather than merely unused.

Scope of this run

This validates correctness and no-regression, not the perf claim. Measured step-time noise floor from the two same-commit baseline runs is ±3% (packed_sequence -3.1%, chat_thd +0.4%), which straddles every per-test timing delta here, so these 50-step CI runs can't confirm the 7.8% figure — the 8-step DiffusionGemma measurement in the description remains the perf evidence.

@akoumpa
akoumpa marked this pull request as draft August 27, 2026 18:50
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.

1 participant