refactor(moe): unify runtime and checkpoint layout - #3411
Conversation
9346a72 to
778a8b7
Compare
5f6dde0 to
7b0e567
Compare
| for event in _pending_combine_events: | ||
| event.current_stream_wait() | ||
| _pending_combine_events.clear() |
There was a problem hiding this comment.
For my own understanding, why did we move from syncing via a single global event to a list of events?
There was a problem hiding this comment.
Also a little unclear to me why we have a global _pending_combine_events list rather than it being an attribute on each DeepEPTokenDispatcher instance. I see we export sync_combine, but don't seem to actually consume it outside of this file, so I'm missing why it's so decoupled from the dispatcher class
| score_before_experts: bool = True, | ||
| ) -> _PendingDispatchState: | ||
| num_input_tokens = hidden_states.shape[0] | ||
| if num_input_tokens == 0: |
There was a problem hiding this comment.
when are we dispatching zero tokens? Looks like a debugging path?
| hidden_states = _unpermute_tokens(hidden_states, state.permuted_indices, state.num_recv_tokens) | ||
| return _DeepEPCombine.apply(hidden_states, state.handle_id) | ||
| combined = _DeepEPCombine.apply(hidden_states, state.handle_id) | ||
| return combined[: state.num_input_tokens] |
There was a problem hiding this comment.
Why is the slice needed? Would have expected num_input_tokens are automatically returned from the combine
|
|
||
| @dataclass(frozen=True) | ||
| class DeepEPDispatchState: | ||
| backend: _DispatchState |
There was a problem hiding this comment.
I find this config a bit confusing. DeepEPDispatchState owns a _DispatchState attr (rather than being a subclass of _DispatchState?) and the owned state is called backend?
| if not chunk_ranges: | ||
| chunk_ranges = [(0, 0)] | ||
|
|
||
| def dispatch_chunk(start: int, end: int) -> _PendingDispatchState: |
There was a problem hiding this comment.
We are redefining this dispatch_chunk function and run_pending_chunk below every time run is called. Refactor to avoid this?
| class LocalDispatchState: | ||
| num_tokens: int | ||
| token_indices_experts_sorted: torch.Tensor | ||
| scores_after_experts: torch.Tensor | None |
There was a problem hiding this comment.
In future work, we can probably simplify this to scores and drop all score_before_experts = True support. More of a note for myself.
| token_indices_experts_sorted: torch.Tensor, | ||
| scores_after_experts: torch.Tensor | None, | ||
| ) -> torch.Tensor: | ||
| if scores_after_experts is not None: |
There was a problem hiding this comment.
Another optimization note for future work: would like to see if dispatching the scores and fusing the scoring into the swiglu is more benficial than just doing the scoring locally in the combine.
| return None | ||
|
|
||
|
|
||
| class TorchTokenDispatcher(LocalTokenDispatcher): |
There was a problem hiding this comment.
nit: some odd inheritance patterns here. True comms-dispatching classes being subclasses of LocalTokenDispatcher is a bit off semantically, but then also we end up overriding many of the base class's methods entirely in the subclasses and have trivial methods elsewhere (e.g. LocalTokenDispatcher.sync. Seems like more of an ABC pattern is expected here
| import torch | ||
|
|
||
|
|
||
| class GroupedGemm(Protocol): |
There was a problem hiding this comment.
just noting that I like the use of Protocols here and throughout, think it's a nice pattern
| computed = iter(torch.autograd.grad(out, wanted, grad_out) if wanted else ()) | ||
| grads = {p: (next(computed) if leaf.requires_grad else None) for leaf, p in zip(leaves, grad_poses)} | ||
| return grads[0], grads[1], grads[2], grads[3], None, grads[5], None, None | ||
| bias = torch.repeat_interleave(bias, num_tokens_per_expert.to(torch.int64), dim=0) |
There was a problem hiding this comment.
repeat_interleave will cause a cuda sync here, which we should be able to avoid by specifying the output_size. Think that should all be known CPU side at launch time
| if self.selection_bias is not None: | ||
| selection_scores = selection_scores + self.selection_bias | ||
| if expert_bias is not None: | ||
| selection_scores = selection_scores + expert_bias | ||
| _, selected_experts_indices = torch.topk( | ||
| selection_scores, | ||
| k=self.top_k, | ||
| dim=1, | ||
| sorted=self.topk_sorted, | ||
| ) | ||
| top_scores = scores.gather(dim=1, index=selected_experts_indices) |
There was a problem hiding this comment.
This all looks a little weird to me. There are scenarios where we maybe adding both an expert_bias and a self.selection_bias buffer to the selection_scores?
Also, I think selection_bias is an excellent semantic name, but am concerned that this will mess up checkpoint loading for checkpoints which persist the expert bias buffer. Usually the buffer has a different name than this, and I'm not seeing any code that handles a possible necessary fqn conversion.
| def prepare_expert_input(self, x: torch.Tensor) -> torch.Tensor: | ||
| return x | ||
|
|
||
| shared_output = self.shared_expert(x) if self.shared_expert is not None else None | ||
| sync_combine() | ||
| routed_output = routed_outputs[0] if len(routed_outputs) == 1 else torch.cat(routed_outputs, dim=0) | ||
| return routed_output if shared_output is None else shared_output + routed_output | ||
| def prepare_expert_output(self, x: torch.Tensor) -> torch.Tensor: | ||
| return x |
There was a problem hiding this comment.
This is purely for Nemotron to override?
There was a problem hiding this comment.
yes, but I think it'a fairly nice "API surface" to expose, so wouldn't mind it
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit fa8cf1e. Configure here.
PR #3411 replaced the per-model expert kernels with one canonical MoE tree, so the V4 implementation now composes the shared pieces instead of carrying its own. `DeepseekV4Experts` becomes a `GroupedExperts` subclass. Its stacked `w1`/`w2`/`w3` were already the canonical `[num_experts, out_features, in_features]` shape, so they simply take the canonical names `gate_proj`/`up_proj`/`down_proj`; the conversion chain renames the on-disk per-expert `w1`/`w2`/`w3` onto them via `routed_experts_op`'s `proj_order`. Both `_run_deepseek_v4_experts_*_impl` kernels, the `expert_parallel` decorator, the `ep_comm_backend` plumbing and `set_ep_comm_backend` go with them: `configure_moe_runtime` now picks the grouped GEMM and the token dispatcher, and `ExpertWeightParallel` shards every expert parameter on dim 0 by name-independent placement. V4's clamped SwiGLU stays V4's. Its limit comes from `config.swiglu_limit`, but the shared `ActivationDispatch` holds stateless classes keyed by name, so there is nowhere in it to put a per-model number. `_ClampedSwiglu` carries the limit as instance state and is assigned to `self.activation`, which is the single point `GroupedExperts.forward` reads, so the shared grouped-GEMM forward is inherited rather than copied. `DeepseekV4MLP` reparents onto `FeedForward`, whose `init_weights` already matches what V4's override did. The load-balancing bias moves with the router: `mlp.expert_bias` becomes `mlp.router.selection_bias`, a buffer the router applies to its own selection rather than a forward argument the layer passes down. `DeepseekV4Router` keeps its `forward` override, since `sqrtsoftplus` is outside the shared `ScoreFuncType` and the scoring chain has no extension hook, but it is re-derived from the current base method so it picks up the fp32 gate path and the int64 token counts. `config.use_grouped_mm` is removed, matching every other model's config: the compute backend is now `[trainer.model.moe.compute]`. Tolerances move from the float32 floor to the bf16 one wherever the routed experts are in the comparison. `GroupedExperts.forward` casts to bfloat16 whatever dtype it is handed, while the deleted for-loop path kept float32, which is what these tests ran on. Verified by forcing the experts back to float32 per-expert matmuls, under which every one of these comparisons passes at its old tolerance unchanged. Each new bound records the measured deviation, and the packed-versus-unpacked invariants keep two orders of magnitude of separation from an actual document leak (measured: 5.1e-1 logits and 2.8 gradients when boundaries are deliberately removed, against bounds of 3e-3 and 8e-2). Two test-side consequences worth naming. `_MOE` moves to the real `swiglu_limit=10.0` default: next to a saturating clamp, bf16 rounding flips which entries get clipped, so a clipped entry's gradient jumps between `silu'(gate) * up` and exactly zero, swinging routed-expert gradients 39% against HF instead of 0.66%. `_CLAMPED_MOE` keeps a saturating limit for the shared expert's clamp test, which stays in float32 and compares exactly. And `test_deepseek_v4_dequantize_e2e_hf.py` never imported the autouse `_seed_rng` fixture that every sibling module does, so its deviations moved with whatever ran before it; importing it makes them reproducible. `test_moe_grouped_mm_experts_match_the_for_loop` is deleted. Both implementations it compared now resolve to `GroupedExperts` with `BF16GroupedGemm`, so it would compare a thing to itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…spatch PR #3411 removed `ep_comm_backend` without an alias, so the SFT config no longer parses. The backend now lives in its own table, `[model.moe.dispatch] type = "deepep"`. The rationale recorded alongside it is rewritten rather than carried over. The measurement still stands: on a 4-layer single-node run the torch dispatch deadlocked every rank while DeepEP completed 5 steps cleanly. The explanation does not. It cited `DeepEPExpertParallel` in `trainer/distributed/expert_parallel.py` and argued that only DeepEP hoists the token dispatch into `MoE.forward()`, keeping its collectives outside the activation-checkpoint boundary. #3411 deleted that class, routed every backend through one `TokenDispatcher.run()` call inside `MoE.forward()`, and dropped the `routed_experts` selective-AC target, so the asymmetry that explanation rested on no longer exists. Whether the torch dispatch still deadlocks is untested on the new runtime, so DeepEP stays and the comment says so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…el entry PR #3411 left one path through `GroupedExperts.forward`, in bfloat16, where `use_grouped_mm=False` used to select per-expert `torch.matmul` in whatever dtype it was handed. On the mini checkpoint that costs under 1% on the residual stream, which is enough to flip near-tied top-k selections in the score-routed layers: 4, 3 and 12 of 256 selections in layers 2 through 4, moving those blocks' outputs by 28% to 40% and the logits by 1.19 on a scale of 6.7. The two hash-routed layers are the control, with a frozen table, zero flips and textbook bfloat16 error. The sensitivity is generic to top-k routing rather than V4's; what is V4's is noticing it, since its tests were written to float32 exactness while `test_qwen3_moe.py` asserts `atol=1e-0` on logits and `atol=2048` on gradients. The note says so, and says the flip counts were measured on V4 alone. Two follow-ups recorded: `scripts/mini_moe.py --arch deepseek_v4` is left failing its `assert max_diff < 0.1` on purpose, since the bound is a precision one and what breaks it is discrete; and the trainer against vLLM should be expected to disagree on a similar share of selections, which lands on the mismatch-KL and wants measuring on the real checkpoint. The `GptOssGroupedExperts` entry goes: it described the `expert_parallel` decorator's fixed signature clashing with a 6-argument call under `use_grouped_mm=False`, and #3411 deleted the decorator, that class and the flag. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The EP note's conclusion survives #3411 but its reasoning does not. It said EP worked because `DeepseekV4Experts` held literal `w1`/`w2`/`w3` params, matching the names torchtitan's `ExpertParallel._partition_fn` sharded by. That class is gone. `ExpertWeightParallel._partition_fn` shards every `named_parameters(recurse=False)` on `Shard(0)` without looking at names, so the stacked `gate_proj`/`up_proj`/`down_proj` inherited from `GroupedExperts` are covered by construction rather than by coincidence of naming. Re-verified rather than assumed: an `ep=8` SFT run on `sft-mini-ep-check.toml` against the mini checkpoint gives finite losses (12.66, 12.24, 13.01), nonzero varying grad norms, no NaNs and 12.3 GiB peak, which also exercises `configure_moe_runtime` discovering `DeepseekV4MoE` and installing the token dispatcher. Recorded that this run uses the default torch dispatch and sets no `[model.ac]`, so it says nothing about the DeepEP-versus-torch deadlock `sft.toml` documents under `ac.mode = "full"`. The config's own header is updated too: it described a fused `gate_up_proj`/`down_proj` layout that `f9573853a` un-fused and #3411 then renamed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PR #3411 replaced the per-model expert kernels with one canonical MoE tree, so the V4 implementation now composes the shared pieces instead of carrying its own. `DeepseekV4Experts` becomes a `GroupedExperts` subclass. Its stacked `w1`/`w2`/`w3` were already the canonical `[num_experts, out_features, in_features]` shape, so they simply take the canonical names `gate_proj`/`up_proj`/`down_proj`; the conversion chain renames the on-disk per-expert `w1`/`w2`/`w3` onto them via `routed_experts_op`'s `proj_order`. Both `_run_deepseek_v4_experts_*_impl` kernels, the `expert_parallel` decorator, the `ep_comm_backend` plumbing and `set_ep_comm_backend` go with them: `configure_moe_runtime` now picks the grouped GEMM and the token dispatcher, and `ExpertWeightParallel` shards every expert parameter on dim 0 by name-independent placement. V4's clamped SwiGLU stays V4's. Its limit comes from `config.swiglu_limit`, but the shared `ActivationDispatch` holds stateless classes keyed by name, so there is nowhere in it to put a per-model number. `_ClampedSwiglu` carries the limit as instance state and is assigned to `self.activation`, which is the single point `GroupedExperts.forward` reads, so the shared grouped-GEMM forward is inherited rather than copied. `DeepseekV4MLP` reparents onto `FeedForward`, whose `init_weights` already matches what V4's override did. The load-balancing bias moves with the router: `mlp.expert_bias` becomes `mlp.router.selection_bias`, a buffer the router applies to its own selection rather than a forward argument the layer passes down. `DeepseekV4Router` keeps its `forward` override, since `sqrtsoftplus` is outside the shared `ScoreFuncType` and the scoring chain has no extension hook, but it is re-derived from the current base method so it picks up the fp32 gate path and the int64 token counts. `config.use_grouped_mm` is removed, matching every other model's config: the compute backend is now `[trainer.model.moe.compute]`. Tolerances move from the float32 floor to the bf16 one wherever the routed experts are in the comparison. `GroupedExperts.forward` casts to bfloat16 whatever dtype it is handed, while the deleted for-loop path kept float32, which is what these tests ran on. Verified by forcing the experts back to float32 per-expert matmuls, under which every one of these comparisons passes at its old tolerance unchanged. Each new bound records the measured deviation, and the packed-versus-unpacked invariants keep two orders of magnitude of separation from an actual document leak (measured: 5.1e-1 logits and 2.8 gradients when boundaries are deliberately removed, against bounds of 3e-3 and 8e-2). Two test-side consequences worth naming. `_MOE` moves to the real `swiglu_limit=10.0` default: next to a saturating clamp, bf16 rounding flips which entries get clipped, so a clipped entry's gradient jumps between `silu'(gate) * up` and exactly zero, swinging routed-expert gradients 39% against HF instead of 0.66%. `_CLAMPED_MOE` keeps a saturating limit for the shared expert's clamp test, which stays in float32 and compares exactly. And `test_deepseek_v4_dequantize_e2e_hf.py` never imported the autouse `_seed_rng` fixture that every sibling module does, so its deviations moved with whatever ran before it; importing it makes them reproducible. `test_moe_grouped_mm_experts_match_the_for_loop` is deleted. Both implementations it compared now resolve to `GroupedExperts` with `BF16GroupedGemm`, so it would compare a thing to itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…spatch PR #3411 removed `ep_comm_backend` without an alias, so the SFT config no longer parses. The backend now lives in its own table, `[model.moe.dispatch] type = "deepep"`. The rationale recorded alongside it is rewritten rather than carried over. The measurement still stands: on a 4-layer single-node run the torch dispatch deadlocked every rank while DeepEP completed 5 steps cleanly. The explanation does not. It cited `DeepEPExpertParallel` in `trainer/distributed/expert_parallel.py` and argued that only DeepEP hoists the token dispatch into `MoE.forward()`, keeping its collectives outside the activation-checkpoint boundary. #3411 deleted that class, routed every backend through one `TokenDispatcher.run()` call inside `MoE.forward()`, and dropped the `routed_experts` selective-AC target, so the asymmetry that explanation rested on no longer exists. Whether the torch dispatch still deadlocks is untested on the new runtime, so DeepEP stays and the comment says so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…el entry PR #3411 left one path through `GroupedExperts.forward`, in bfloat16, where `use_grouped_mm=False` used to select per-expert `torch.matmul` in whatever dtype it was handed. On the mini checkpoint that costs under 1% on the residual stream, which is enough to flip near-tied top-k selections in the score-routed layers: 4, 3 and 12 of 256 selections in layers 2 through 4, moving those blocks' outputs by 28% to 40% and the logits by 1.19 on a scale of 6.7. The two hash-routed layers are the control, with a frozen table, zero flips and textbook bfloat16 error. The sensitivity is generic to top-k routing rather than V4's; what is V4's is noticing it, since its tests were written to float32 exactness while `test_qwen3_moe.py` asserts `atol=1e-0` on logits and `atol=2048` on gradients. The note says so, and says the flip counts were measured on V4 alone. Two follow-ups recorded: `scripts/mini_moe.py --arch deepseek_v4` is left failing its `assert max_diff < 0.1` on purpose, since the bound is a precision one and what breaks it is discrete; and the trainer against vLLM should be expected to disagree on a similar share of selections, which lands on the mismatch-KL and wants measuring on the real checkpoint. The `GptOssGroupedExperts` entry goes: it described the `expert_parallel` decorator's fixed signature clashing with a 6-argument call under `use_grouped_mm=False`, and #3411 deleted the decorator, that class and the flag. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The EP note's conclusion survives #3411 but its reasoning does not. It said EP worked because `DeepseekV4Experts` held literal `w1`/`w2`/`w3` params, matching the names torchtitan's `ExpertParallel._partition_fn` sharded by. That class is gone. `ExpertWeightParallel._partition_fn` shards every `named_parameters(recurse=False)` on `Shard(0)` without looking at names, so the stacked `gate_proj`/`up_proj`/`down_proj` inherited from `GroupedExperts` are covered by construction rather than by coincidence of naming. Re-verified rather than assumed: an `ep=8` SFT run on `sft-mini-ep-check.toml` against the mini checkpoint gives finite losses (12.66, 12.24, 13.01), nonzero varying grad norms, no NaNs and 12.3 GiB peak, which also exercises `configure_moe_runtime` discovering `DeepseekV4MoE` and installing the token dispatcher. Recorded that this run uses the default torch dispatch and sets no `[model.ac]`, so it says nothing about the DeepEP-versus-torch deadlock `sft.toml` documents under `ac.mode = "full"`. The config's own header is updated too: it described a fused `gate_up_proj`/`down_proj` layout that `f9573853a` un-fused and #3411 then renamed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PR #3411 replaced the per-model expert kernels with one canonical MoE tree, so the V4 implementation now composes the shared pieces instead of carrying its own. `DeepseekV4Experts` becomes a `GroupedExperts` subclass. Its stacked `w1`/`w2`/`w3` were already the canonical `[num_experts, out_features, in_features]` shape, so they simply take the canonical names `gate_proj`/`up_proj`/`down_proj`; the conversion chain renames the on-disk per-expert `w1`/`w2`/`w3` onto them via `routed_experts_op`'s `proj_order`. Both `_run_deepseek_v4_experts_*_impl` kernels, the `expert_parallel` decorator, the `ep_comm_backend` plumbing and `set_ep_comm_backend` go with them: `configure_moe_runtime` now picks the grouped GEMM and the token dispatcher, and `ExpertWeightParallel` shards every expert parameter on dim 0 by name-independent placement. V4's clamped SwiGLU stays V4's. Its limit comes from `config.swiglu_limit`, but the shared `ActivationDispatch` holds stateless classes keyed by name, so there is nowhere in it to put a per-model number. `_ClampedSwiglu` carries the limit as instance state and is assigned to `self.activation`, which is the single point `GroupedExperts.forward` reads, so the shared grouped-GEMM forward is inherited rather than copied. `DeepseekV4MLP` reparents onto `FeedForward`, whose `init_weights` already matches what V4's override did. The load-balancing bias moves with the router: `mlp.expert_bias` becomes `mlp.router.selection_bias`, a buffer the router applies to its own selection rather than a forward argument the layer passes down. `DeepseekV4Router` keeps its `forward` override, since `sqrtsoftplus` is outside the shared `ScoreFuncType` and the scoring chain has no extension hook, but it is re-derived from the current base method so it picks up the fp32 gate path and the int64 token counts. `config.use_grouped_mm` is removed, matching every other model's config: the compute backend is now `[trainer.model.moe.compute]`. Tolerances move from the float32 floor to the bf16 one wherever the routed experts are in the comparison. `GroupedExperts.forward` casts to bfloat16 whatever dtype it is handed, while the deleted for-loop path kept float32, which is what these tests ran on. Verified by forcing the experts back to float32 per-expert matmuls, under which every one of these comparisons passes at its old tolerance unchanged. Each new bound records the measured deviation, and the packed-versus-unpacked invariants keep two orders of magnitude of separation from an actual document leak (measured: 5.1e-1 logits and 2.8 gradients when boundaries are deliberately removed, against bounds of 3e-3 and 8e-2). Two test-side consequences worth naming. `_MOE` moves to the real `swiglu_limit=10.0` default: next to a saturating clamp, bf16 rounding flips which entries get clipped, so a clipped entry's gradient jumps between `silu'(gate) * up` and exactly zero, swinging routed-expert gradients 39% against HF instead of 0.66%. `_CLAMPED_MOE` keeps a saturating limit for the shared expert's clamp test, which stays in float32 and compares exactly. And `test_deepseek_v4_dequantize_e2e_hf.py` never imported the autouse `_seed_rng` fixture that every sibling module does, so its deviations moved with whatever ran before it; importing it makes them reproducible. `test_moe_grouped_mm_experts_match_the_for_loop` is deleted. Both implementations it compared now resolve to `GroupedExperts` with `BF16GroupedGemm`, so it would compare a thing to itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…spatch PR #3411 removed `ep_comm_backend` without an alias, so the SFT config no longer parses. The backend now lives in its own table, `[model.moe.dispatch] type = "deepep"`. The rationale recorded alongside it is rewritten rather than carried over. The measurement still stands: on a 4-layer single-node run the torch dispatch deadlocked every rank while DeepEP completed 5 steps cleanly. The explanation does not. It cited `DeepEPExpertParallel` in `trainer/distributed/expert_parallel.py` and argued that only DeepEP hoists the token dispatch into `MoE.forward()`, keeping its collectives outside the activation-checkpoint boundary. #3411 deleted that class, routed every backend through one `TokenDispatcher.run()` call inside `MoE.forward()`, and dropped the `routed_experts` selective-AC target, so the asymmetry that explanation rested on no longer exists. Whether the torch dispatch still deadlocks is untested on the new runtime, so DeepEP stays and the comment says so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…el entry PR #3411 left one path through `GroupedExperts.forward`, in bfloat16, where `use_grouped_mm=False` used to select per-expert `torch.matmul` in whatever dtype it was handed. On the mini checkpoint that costs under 1% on the residual stream, which is enough to flip near-tied top-k selections in the score-routed layers: 4, 3 and 12 of 256 selections in layers 2 through 4, moving those blocks' outputs by 28% to 40% and the logits by 1.19 on a scale of 6.7. The two hash-routed layers are the control, with a frozen table, zero flips and textbook bfloat16 error. The sensitivity is generic to top-k routing rather than V4's; what is V4's is noticing it, since its tests were written to float32 exactness while `test_qwen3_moe.py` asserts `atol=1e-0` on logits and `atol=2048` on gradients. The note says so, and says the flip counts were measured on V4 alone. Two follow-ups recorded: `scripts/mini_moe.py --arch deepseek_v4` is left failing its `assert max_diff < 0.1` on purpose, since the bound is a precision one and what breaks it is discrete; and the trainer against vLLM should be expected to disagree on a similar share of selections, which lands on the mismatch-KL and wants measuring on the real checkpoint. The `GptOssGroupedExperts` entry goes: it described the `expert_parallel` decorator's fixed signature clashing with a 6-argument call under `use_grouped_mm=False`, and #3411 deleted the decorator, that class and the flag. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The EP note's conclusion survives #3411 but its reasoning does not. It said EP worked because `DeepseekV4Experts` held literal `w1`/`w2`/`w3` params, matching the names torchtitan's `ExpertParallel._partition_fn` sharded by. That class is gone. `ExpertWeightParallel._partition_fn` shards every `named_parameters(recurse=False)` on `Shard(0)` without looking at names, so the stacked `gate_proj`/`up_proj`/`down_proj` inherited from `GroupedExperts` are covered by construction rather than by coincidence of naming. Re-verified rather than assumed: an `ep=8` SFT run on `sft-mini-ep-check.toml` against the mini checkpoint gives finite losses (12.66, 12.24, 13.01), nonzero varying grad norms, no NaNs and 12.3 GiB peak, which also exercises `configure_moe_runtime` discovering `DeepseekV4MoE` and installing the token dispatcher. Recorded that this run uses the default torch dispatch and sets no `[model.ac]`, so it says nothing about the DeepEP-versus-torch deadlock `sft.toml` documents under `ac.mode = "full"`. The config's own header is updated too: it described a fused `gate_up_proj`/`down_proj` layout that `f9573853a` un-fused and #3411 then renamed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Summary
Model and checkpoint contract
gate_proj,up_proj, anddown_projparameters. Non-gated experts omitgate_proj.mlp.router.gate, routed experts atmlp.experts, and optional shared experts insidemlp.mlp.router.selection_bias; model-specific checkpoint names are converted only at the HF boundary.mlp.shared_expert.output_gate, with conversion mapping the standalone Hugging Face key at the boundary.FeedForwardimplementation for dense MLPs, shared experts, and grouped experts.FeedForward/subclass orNone, whileMoEArgsandMoEnever infer or construct one..prime-v1in reusable<conversion_dir>/primecaches so legacy Prime layouts fail before loading.MoE runtime contract
MoEArgsremains architecture-only.TokenDispatcheras the structural interface while a shared base owns the canonical run/synchronize sequence; local, Torch, and DeepEP implementations own only their dispatch/combine details.GroupedExperts.forwardand router confidence accounting directly inTokenChoiceTopKRouter.forward.Shard(0)placement.Public contracts
Checkpoint tree:
mlp.router.gate:nn.Linearmlp.router.selection_bias: optional persistent selection-only buffermlp.experts.gate_proj: stackednn.Parameterfor gated experts, absent for non-gated expertsmlp.experts.up_proj: stackednn.Parametermlp.experts.down_proj: stackednn.Parametermlp.shared_expert.{gate_proj,up_proj,down_proj}:FeedForwardprojections when presentmlp.shared_expert.output_gate: Qwen3.5-only scalarnn.Linear[num_experts, output_features, input_features]Runtime configuration:
DeepEP owns its tuning fields:
This is intentionally breaking.
enable_grouped_gemm,enable_a2a,ep_comm_backend,deepep_num_sms, anddeepep_token_chunk_sizeare removed without aliases.Validation completed
FeedForwardcompile validation passed after the ownership cleanup (Slurm 2601).torch.compile(fullgraph=True)forward/backward passed for the full MoE and sharedFeedForwardacross all supported topology/activation combinations.mxfp8_moewheel build includes its public module, manifest, and license; the module imports against the pinned torchao build.reverse_text_moeintegration passed end-to-end on two H200s with an isolated conversion cache (Slurm 2607)..prime-v1only after the final all-rank barrier.git diff --checkpassed.Deliberate exclusions
Remaining before marking ready
mxfp8_moe, and update thepyproject.toml/lock wheel pins. The currently pinned v0.8.0 wheel does not contain this module.Note
High Risk
This changes the MoE forward/backward path, expert-parallel dispatch, checkpoint key layout, and trainer TOML in breaking ways across all custom MoE models.
Overview
Breaking trainer config: MoE is configured via
[trainer.model.moe.compute](bf16,deepgemm_fp8,mxfp8) and[trainer.model.moe.dispatch](torchwithtransport, ordeepepwithnum_sms/token_chunk_size). Dense[trainer.model.quantization]no longer drives expert GEMMs or EP transport. Removed flags includeep_comm_backend,deepep_*,moe_use_grouped_mm,moe_fused_kernel, and quantizationenable_grouped_gemm/enable_a2a.Unified MoE execution: All custom models share one
MoEpath—TokenChoiceTopKRouter, stackedGroupedExperts(gate_proj/up_proj/down_proj), and a pluggableTokenDispatcher(LocalTokenDispatcher,TorchTokenDispatcher,MXFP8TorchTokenDispatcher,DeepEPTokenDispatcher).configure_moe_runtimewires grouped GEMM backends and dispatch at setup; EP weight sharding is onlyExpertWeightParallel. DeepEP dispatch/combine, grouped-GEMM permutation, and chunk pipelining live in the new dispatcher layer instead of ad hocMoEhooks.Checkpoint and model surface: PrimeRL checkpoints rename routed weights from
w1/w2/w3togate/down/up_proj, move selection bias tomlp.router.selection_bias, and add GPT-OSS HF↔prime conversion.LatentMoE/ Nemotron-specific expert classes are folded into the canonical tree; dense layers useFeedForward. Reusableprimeconversion caches must include a.prime-v1marker.Removed / docs: Fused
flash_moetrainer integration andbench_fused_moe.pyare dropped;routed_expertsselective activation checkpointing is removed. Docs and examples (e.g. GLM-5) are updated for the new tables.Reviewed by Cursor Bugbot for commit ffd5be7. Bugbot is set up for automated code reviews on this repo. Configure here.