Repository navigation
MoE FFN skeleton for DFlash-family drafters (contracts, config, seams) - #811
sherlockwu wants to merge 2 commits into
Conversation
… seams) Lays out specforge/modeling/draft/moe as one configurable MoE layer with swappable components, and wires every seam a target-family implementation needs — with no routing math yet: - config.py: MoEConfig, moe_preset registry, resolve_moe_config(). Architecture keys use the target checkpoints' native HF names at the draft-JSON top level; training-only knobs live under dflash_config as moe_*. - router/balance/experts/shared.py: component contracts + named registries (score functions, routers, balance controllers, experts backends, shared experts). BalanceController pins the deferred-update timing that keeps activation checkpointing correct. - layer.py: MoELayer composes gate/experts/shared_experts (official attribute names); build_ffn() is the dense/MoE switch — dense drafts get the kernel provider's MLP verbatim. - hooks.py: apply_pending_balance_updates / collect_moe_aux_loss / collect_moe_metrics over any module tree. - state_dict.py: to/from_checkpoint_state_dict boundary (module layout vs official file naming), applied in the FSDP backend, warm start, materialize_draft and both exporters. FSDP full-state-dict hooks need the module's own FQNs, so the rename cannot live in state_dict(). - init.py: WarmStartPlan / select_target_experts. - dflash.py: decoder layers build their FFN through build_ffn; _init_weights reaches MoE bare Parameters; the model forward applies pending balance updates in training. DFlash/DSpark strategies add moe/* load metrics. - DESIGN.md + customization docs; tests pin the contracts with stub components. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
RoutingResult carries the differentiable pre-selection scores (optional), and BalanceController.observe() receives the whole result instead of bare counts, so a policy can build an auxiliary balance loss for the same forward. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
(cc catch - human verify)
Resume path bypasses the checkpoint-naming boundary: Trainer.__init__ loads state["draft_state_dict"] (file naming) straight into model.draft_model (L347-L349) and raises on unexpected keys. Once an experts backend registers a converter, every resume_from of an MoE drafter fails. Fix: convert like _load_module_state_dict does.
diff --git a/specforge/training/trainer.py b/specforge/training/trainer.py
index d465b947..594ca5b2 100644
--- a/specforge/training/trainer.py
+++ b/specforge/training/trainer.py
@@ -344,7 +344,12 @@ class Trainer:
f"{key}={comparison_current}; "
f"resume with the original configuration"
)
- saved_weights = state["draft_state_dict"]
+ # Checkpoint FILES use the official parameter naming; the module may
+ # use a native MoE layout (stacked experts). Convert at this boundary
+ # exactly like FSDPTrainingBackend._load_module_state_dict does.
+ from specforge.modeling.draft.moe import from_checkpoint_state_dict
+
+ saved_weights = from_checkpoint_state_dict(state["draft_state_dict"])
load_result = model.draft_model.load_state_dict(saved_weights, strict=False)
loaded = len(saved_weights) - len(load_result.unexpected_keys)
missing_keys = set(load_result.missing_keys)| def _moe_metrics(model_wrapper: nn.Module) -> Dict[str, Any]: | ||
| """``moe/...`` load diagnostics of the wrapped draft; ``{}`` for dense drafts.""" | ||
| from specforge.modeling.draft.moe import collect_moe_metrics | ||
|
|
||
| draft_model = getattr(model_wrapper, "draft_model", None) | ||
| if draft_model is None: | ||
| return {} | ||
| return collect_moe_metrics(draft_model) |
There was a problem hiding this comment.
(cc catch - human verify)
(I know we seldom use DDP training but it's better to modify)
_moe_metrics silently returns {} on the NO_SHARD (DDP) recipe: strategies get the wrapped module (trainer.py L430-L437), and DDP, unlike FSDP, does not forward draft_model (L55-L62). Same root cause already disables the DFlash2 selector loss under DDP (_selector_loss_alpha). Fix: unwrap .module like Eagle3TrainStrategy does.
diff --git a/specforge/training/strategies/base.py b/specforge/training/strategies/base.py
index cbc253da..dcff526c 100644
--- a/specforge/training/strategies/base.py
+++ b/specforge/training/strategies/base.py
@@ -52,11 +52,24 @@ class StepContext:
total_steps: Optional[int] = None
+def _unwrap_parallel(model: nn.Module) -> nn.Module:
+ """Reach the composite model behind a parallel wrapper.
+
+ Strategies receive ``backend.prepare_model(...)``'s return value. FSDP
+ forwards attribute lookups to the wrapped module; DDP does not, so
+ ``getattr(ddp, "draft_model")`` / ``getattr(ddp, "selector_loss_alpha")``
+ would silently miss. Same unwrap rule as ``Eagle3TrainStrategy``.
+ """
+ while not hasattr(model, "draft_model") and hasattr(model, "module"):
+ model = model.module
+ return model
+
+
def _moe_metrics(model_wrapper: nn.Module) -> Dict[str, Any]:
"""``moe/...`` load diagnostics of the wrapped draft; ``{}`` for dense drafts."""
from specforge.modeling.draft.moe import collect_moe_metrics
- draft_model = getattr(model_wrapper, "draft_model", None)
+ draft_model = getattr(_unwrap_parallel(model_wrapper), "draft_model", None)
if draft_model is None:
return {}
return collect_moe_metrics(draft_model)
@@ -478,20 +491,20 @@ class DFlashTrainStrategy(DraftTrainStrategy):
return next(self.dflash_model.parameters()).device
def _selector_loss_alpha(self, ctx: Optional[StepContext]) -> float:
- target = float(getattr(self.dflash_model, "selector_loss_alpha", 0.0))
+ model = _unwrap_parallel(self.dflash_model)
+ target = float(getattr(model, "selector_loss_alpha", 0.0))
if target <= 0 or ctx is None or not ctx.total_steps:
return target
total_steps = int(ctx.total_steps)
warmup_steps = int(
- total_steps
- * float(getattr(self.dflash_model, "selector_warmup_ratio", 0.0))
+ total_steps * float(getattr(model, "selector_warmup_ratio", 0.0))
)
if ctx.global_step < warmup_steps:
return 0.0
ramp_steps = int(
- total_steps * float(getattr(self.dflash_model, "selector_ramp_ratio", 0.0))
+ total_steps * float(getattr(model, "selector_ramp_ratio", 0.0))
)
if ramp_steps <= 0:
return target| @@ -0,0 +1,101 @@ | |||
| # MoE FFN Design (`specforge.modeling.draft.moe`) | |||
There was a problem hiding this comment.
This doc really help me quickly understand the design but I not sure if it's proper to leave it or put it here.
| def is_moe_config(config: Any) -> bool: | ||
| """True when a draft config asks for an MoE FFN (``n_routed_experts > 0``).""" | ||
| value = _get(config, "n_routed_experts", 0) | ||
| return int(value or 0) > 0 |
There was a problem hiding this comment.
Values like -1 and 0.5 silently select dense layers. Could we reject invalid expert counts instead of defaulting to dense?
resolve_moe_config({"n_routed_experts": -1}) # None
resolve_moe_config({"n_routed_experts": 0.5}) # None
Reworks the sgl-project#811 skeleton into one configurable MoE layer whose target families only add files. This commit keeps sgl-project#811's scope (contracts, config, seams) plus the components every family shares; the routing, balancing and serving options the Qwen and DeepSeek recipes need follow in the next commit. - base.py: Registry, RoutingResult and the TrainingState contract (observe, apply_pending_update, aux_loss, metrics, reset_parameters) that the router, balance controller and experts share. - config.py: MoEConfig with architecture and training keys kept apart, presets that supply the defaults and a checkpoint layout, positive-integer validation, and an error for n_routed_experts without a moe_preset. - router.py: the score-function and router registries and a softmax top-k router; renormalized combine weights carry a 1e-20 guard against 0/0. - balance.py: the BalanceController contract and the "none" policy. - experts.py: stacked grouped experts (sorted_loop and grouped_mm dispatch) and the SwiGLU shared expert. - layer.py: MoELayer combines routed and shared experts in fp32 and casts once, the official DeepSeek order; build_ffn and model-level hooks. - checkpoint.py: checkpoint layouts converted at every file boundary (backend save/load, resume, warm start, from_pretrained, materialize, to_hf), and select_target_experts. - families/: where target families register presets and layouts; the core never imports them. Integration: DraftTrainStrategy.finalize_step_output adds the aux loss and moe/* metrics for every strategy (TrainerCore calls it, so the optimizer boundary host-sync test's stand-in strategy now subclasses DraftTrainStrategy, the type TrainerCore takes), and AutoDraftModel.from_pretrained reads MoE files through the layout; to_sglang (EAGLE3 only) is left untouched.
…chitecture - specforge/modeling/draft/dspark_v4.py: training-side port of the DSpark head bundled with DeepSeek-V4-Flash-0731 — three DeepSeek-V4 blocks (MLA-style low-rank attention, MoE via draft/moe.py, mHC hyper-connections) under the official mtp.* naming, so trained state dicts round-trip into the bundled layout without renaming - training.attention_backend=native: the draft model implements its own block/window attention (no wrapper-built masks); dspark providers and assembly accept the DSparkV4DraftModel architecture; the confidence head reads its own feature stream via pop_confidence_hidden - scripts: official-weight download, init-from-target (identity/average main_proj, --stage-layers for spread-capture drafts), export bundling into the official servable layout, numeric parity gate vs the reference implementation, step-time benchmark - recipes: official-arch two-node H200 + runbook, 0813-traffic 32K on H200 and B200 2-node stacks, with launch scripts (cherry picked from commit 67fea1a) [Yusheng Su: cherry-picked onto the MoE framework stack (sgl-project#811 rework) without its parent sgl-project#799, so dspark_v4.py's `from .moe import MoEGate, SparseMoE` no longer resolves until a follow-up commit moves the FFN onto MoELayer. examples/configs/README.md conflict: kept both paragraphs and dropped the sentence about sgl-project#799's `...-moe.yaml` ablation recipe.] [Yusheng Su: rebased onto main a580ec5, which gave _dspark_objective_chunk_terms a positional block_index and a keyword-only total_blocks; confidence_hidden now follows block_index, so checkpointed_chunk_reduce still slices it with the other block inputs.]
Part 1 of 5 of the sgl-project#811 skeleton commit (ec6dfda), split for review. - _registry.py: the named-registry helper the MoE registries share. - config.py: MoEConfig, the moe_preset registry and resolve_moe_config(). Architecture keys use the target checkpoints' native HF names at the draft-JSON top level; training-only knobs live under dflash_config as moe_*. - __init__.py: the package entry point, exporting the config API. - tests: config resolution, preset defaults and overrides, validation.
Part 2 of 5 of the sgl-project#811 skeleton commit (ec6dfda), split for review. - router.py: RoutingResult, the score-function registry and the Router contract (hidden states -> top-k choices + combine weights) with its registry. - balance.py: the BalanceController contract (selection-only score shift, optional aux loss, metrics, deferred updates) and the "none" policy. - experts.py: the RoutedExperts contract and the experts-backend registry. - shared.py: the SharedExpert contract and its registry. - tests: stub components for every contract, registered by the tests, and the registries' error messages.
…egies Part 3 of 5 of the sgl-project#811 skeleton commit (ec6dfda), split for review. - layer.py: MoELayer composes gate (router + balance controller), experts and shared_experts under the official attribute names; build_ffn() is the dense/MoE switch, so dense drafts get the kernel provider's MLP verbatim. - hooks.py: apply_pending_balance_updates, collect_moe_aux_loss and collect_moe_metrics over any module tree. - dflash.py: decoder layers build their FFN through build_ffn; _init_weights reaches the MoE layers' bare Parameters; the model forward applies pending balance updates in training. The moe import is placed in isort order (the original commit failed the isort hook here). - strategies/base.py: the DFlash and DSpark strategies add moe/* load metrics. - tests: layer composition, the model-level hooks and the DFlash wiring.
Part 4 of 5 of the sgl-project#811 skeleton commit (ec6dfda), split for review. - state_dict.py: the to/from_checkpoint_state_dict boundary between the modules' native layout and the official file naming, with a registry of converters. FSDP's full-state-dict hooks need the module's own FQNs, so the rename cannot live in state_dict(). - init.py: WarmStartPlan, select_target_experts and plan_warm_start. - The boundary is applied in the FSDP backend's save and load, materialize_draft, warm start, and both exporters. - tests: the conversion boundary and warm-start planning.
Part 5 of 5 of the sgl-project#811 skeleton commit (ec6dfda), split for review. Together the five parts are that commit, except for its two lint fixes: the moe import in dflash.py is in isort order (part 3), and the tests do not import the unused MoEConfig. - moe/DESIGN.md: responsibility, why the draft matches the target's MoE, the package layout, the contracts that matter, extension points. - customization.md: the MoE section for DFlash-family drafts.
Reworks the sgl-project#811 skeleton into one configurable MoE layer whose target families only add files. This commit keeps sgl-project#811's scope (contracts, config, seams) plus the components every family shares; the routing, balancing and serving options the Qwen and DeepSeek recipes need follow in the next commit. - base.py: Registry, RoutingResult and the TrainingState contract (observe, apply_pending_update, aux_loss, metrics, reset_parameters) that the router, balance controller and experts share. - config.py: MoEConfig with architecture and training keys kept apart, presets that supply the defaults and a checkpoint layout, positive-integer validation, and an error for n_routed_experts without a moe_preset. - router.py: the score-function and router registries and a softmax top-k router; renormalized combine weights carry a 1e-20 guard against 0/0. - balance.py: the BalanceController contract and the "none" policy. - experts.py: the RoutedExperts contract and its registry, with stacked grouped experts (sorted_loop and grouped_mm dispatch) as "grouped". - shared.py: the SharedExpert contract and its registry, with the SwiGLU shared expert as "swiglu". - layer.py: MoELayer combines routed and shared experts in fp32 and casts once, the official DeepSeek order; build_ffn and model-level hooks. - checkpoint.py: checkpoint layouts converted at every file boundary (backend save/load, resume, warm start, from_pretrained, materialize, to_hf), and select_target_experts. - families/: where target families register presets and layouts; the core never imports them. Integration: DraftTrainStrategy.finalize_step_output adds the aux loss and moe/* metrics for every strategy (TrainerCore calls it, so the optimizer boundary host-sync test's stand-in strategy now subclasses DraftTrainStrategy, the type TrainerCore takes), and AutoDraftModel.from_pretrained reads MoE files through the layout; to_sglang (EAGLE3 only) is left untouched.
…chitecture - specforge/modeling/draft/dspark_v4.py: training-side port of the DSpark head bundled with DeepSeek-V4-Flash-0731 — three DeepSeek-V4 blocks (MLA-style low-rank attention, MoE via draft/moe.py, mHC hyper-connections) under the official mtp.* naming, so trained state dicts round-trip into the bundled layout without renaming - training.attention_backend=native: the draft model implements its own block/window attention (no wrapper-built masks); dspark providers and assembly accept the DSparkV4DraftModel architecture; the confidence head reads its own feature stream via pop_confidence_hidden - scripts: official-weight download, init-from-target (identity/average main_proj, --stage-layers for spread-capture drafts), export bundling into the official servable layout, numeric parity gate vs the reference implementation, step-time benchmark - recipes: official-arch two-node H200 + runbook, 0813-traffic 32K on H200 and B200 2-node stacks, with launch scripts (cherry picked from commit 67fea1a) [Yusheng Su: cherry-picked onto the MoE framework stack (sgl-project#811 rework) without its parent sgl-project#799, so dspark_v4.py's `from .moe import MoEGate, SparseMoE` no longer resolves until a follow-up commit moves the FFN onto MoELayer. examples/configs/README.md conflict: kept both paragraphs and dropped the sentence about sgl-project#799's `...-moe.yaml` ablation recipe.] [Yusheng Su: rebased onto main a580ec5, which gave _dspark_objective_chunk_terms a positional block_index and a keyword-only total_blocks; confidence_hidden now follows block_index, so checkpointed_chunk_reduce still slices it with the other block inputs.]
sgl-project#811's routed-expert and shared-expert contracts and their registries stay; this adds the built-in implementations, so a draft that names a preset builds a working layer and the tests run real code. - experts.py: GroupedExperts, registered as "grouped", holds the routed experts as three stacked [E, out, in] parameters with sorted-segment dispatch (sorted_loop, or torch._grouped_mm on CUDA) and returns a fresh fp32 accumulator, which the RoutedExperts contract now requires. Both contracts implement TrainingState. - shared.py: SwiGLUSharedExpert, registered as "swiglu". - router.py: TopKRouter ("topk") picks the top-k on the balance controller's selection scores and combines with the raw scores, all in fp32; a softmax score function is registered and get_score_function goes (SCORE_FUNCTIONS.get does the same). Routers implement TrainingState and receive tokens_per_sample. - layer.py: MoELayer adds the shared expert into the fp32 routed sum, casts once, and passes tokens_per_sample to the router. - tests: the router, experts and shared-expert stubs go; the layer tests run the built-in components, with reference checks of the routed sum, the grouped_mm path and the single cast, and a check that the layer builds the implementations the config names.
- layer.py: MoELayer drives the router, balance controller, routed experts and shared expert through one TrainingState loop (components(), in a fixed order) for observe, apply_pending_update, aux_loss, metrics and reset_parameters, where it used to call the balance controller alone; MoELayer.apply_pending_balance_update is now apply_pending_update. The model-level hooks (iter_moe_layers, apply_pending_balance_updates, collect_moe_aux_loss, collect_moe_metrics) move here and hooks.py goes. - balance.py: BalanceController implements TrainingState, whose no-op defaults and timing rules in base.py replace its own copies; the "none" policy is listed. - dflash.py: the pending-update comment points at base.py. - tests: the config, router, layer, hook and draft-model tests use purpose-built balance controllers instead of sgl-project#811's shared stub and keep sgl-project#811's wiring checks: attribute names through build_ffn, eval forwards not observing, pending updates reaching every layer, and the DFlash forward applying them in training only. The checkpoint and trainer tests change with their code in the next two commits.
Checkpoint files name an MoE layer's tensors the way the target family does, while modules keep a native layout (stacked [E, out, in] experts). checkpoint.py replaces sgl-project#811's state_dict.py converters with layouts registered by name: the preset picks one (moe_layout overrides it), only that layout runs (sgl-project#811's converters all ran on every state dict), and it maps one MoE layer at a time. - checkpoint.py: CheckpointLayout(to_file, from_file), the LAYOUTS registry with the "native" layout (files keep module names), and to/from_checkpoint_state_dict(state, owner), where owner is the draft model or its config; dense drafts pass through unchanged. select_target_experts moves here; init.py goes with WarmStartPlan and plan_warm_start. - config.py: MoEConfig.layout (draft JSON key moe_layout, preset default); an unknown layout fails when the config resolves. - families/__init__.py: where target families register a preset and a layout; core modules never import it. - Every file read and write converts through the draft's layout: FSDPTrainingBackend save/load, trainer resume, warm start, AutoDraftModel.from_pretrained (MoE files are read memory-mapped and regrouped, which HF from_pretrained cannot do), materialize_draft and the HF exporter. to_sglang (EAGLE3 only) goes back to main's version. - __init__.py: the public API is the contracts, the registration functions, the layer and its hooks, and the checkpoint boundary; the registries and build functions stay in their modules. - tests: the layout config keys, a per-expert test layout's round trip (with layer-prefix disambiguation), owner resolution, the native identity, select_target_experts, and a from_pretrained round trip through the layout.
sgl-project#811 added the moe/* metrics inside the DFlash and DSpark strategies' forward_loss and collected the auxiliary loss without adding it to the objective. - strategies/base.py: DraftTrainStrategy.finalize_step_output adds the draft's summed MoE auxiliary loss to whichever term backpropagates (loss, or the loss_terms numerator scaled by this micro-batch's denominator, the weighting the objective gets) and the layer-averaged moe/* metrics; drafts without MoE layers get their output back unchanged. _moe_metrics and its two call sites go. - controller.py: TrainerCore calls it after forward_loss on every training micro-step; evaluation calls forward_loss alone, so eval losses exclude the auxiliary term. - test_dflash_host_syncs.py: the optimizer-boundary test's stand-in strategy subclasses DraftTrainStrategy, the type TrainerCore takes. - tests: the aux loss reaches the gradient with and without loss_terms, the moe/* metrics reach a strategy with no MoE code, and a dense step's output is untouched.
- moe/DESIGN.md: rewritten for the framework core: the two ways a draft uses the layer (build_ffn in the DFlash family, MoELayer directly in a drafter with its own block, which stays its own class), the package layout, the rules (core never imports families, every choice is explicit, a new family only adds files), and the contracts: detection keys on the preset, one TrainingState loop, deferred updates, the official accumulation order, attribute names, naming converted at the file boundary, and model-level terms for every strategy. sgl-project#811's notes that still hold stay: the Megatron-Core split, why the drafter's MoE matches the target's (warm start, serving, latency), and the extension points, including experts backends and shared experts. - customization.md: drafts opt in by naming a moe_preset; architecture and training-only keys, losses and metrics without strategy code, checkpoints and exports through the layout, how new families and components register, and how a drafter with its own architecture uses MoELayer. - training/DESIGN.md: DraftTrainStrategy owns finalize_step_output, which TrainerCore calls after forward_loss.
…chitecture - specforge/modeling/draft/dspark_v4.py: training-side port of the DSpark head bundled with DeepSeek-V4-Flash-0731 — three DeepSeek-V4 blocks (MLA-style low-rank attention, MoE via draft/moe.py, mHC hyper-connections) under the official mtp.* naming, so trained state dicts round-trip into the bundled layout without renaming - training.attention_backend=native: the draft model implements its own block/window attention (no wrapper-built masks); dspark providers and assembly accept the DSparkV4DraftModel architecture; the confidence head reads its own feature stream via pop_confidence_hidden - scripts: official-weight download, init-from-target (identity/average main_proj, --stage-layers for spread-capture drafts), export bundling into the official servable layout, numeric parity gate vs the reference implementation, step-time benchmark - recipes: official-arch two-node H200 + runbook, 0813-traffic 32K on H200 and B200 2-node stacks, with launch scripts (cherry picked from commit 67fea1a) [Yusheng Su: cherry-picked onto the MoE framework stack (sgl-project#811 rework) without its parent sgl-project#799, so dspark_v4.py's `from .moe import MoEGate, SparseMoE` no longer resolves until a follow-up commit moves the FFN onto MoELayer. examples/configs/README.md conflict: kept both paragraphs and dropped the sentence about sgl-project#799's `...-moe.yaml` ablation recipe.] [Yusheng Su: rebased onto main a580ec5, which gave _dspark_objective_chunk_terms a positional block_index and a keyword-only total_blocks; confidence_hidden now follows block_index, so checkpointed_chunk_reduce still slices it with the other block inputs.]
sgl-project#811's router, routed-expert and shared-expert contracts and their registries stay; this adds the built-in implementations, so a draft that names a preset builds a working layer and the tests run real code. - router.py: TopKRouter, registered as "topk", picks the top-k on the balance controller's selection scores and combines with the raw scores, all in fp32; a softmax score function is registered and get_score_function goes (SCORE_FUNCTIONS.get does the same). Routers implement TrainingState and receive tokens_per_sample. - experts.py: GroupedExperts, registered as "grouped", holds the routed experts as three stacked [E, out, in] parameters with sorted-segment dispatch (sorted_loop, or torch._grouped_mm on CUDA) and returns a fresh fp32 accumulator, which the RoutedExperts contract now requires. - shared.py: SwiGLUSharedExpert, registered as "swiglu". Both expert contracts implement TrainingState. - layer.py: MoELayer adds the shared expert into the fp32 routed sum, casts once, and passes tokens_per_sample to the router. - tests: the router, experts and shared-expert stubs go; the layer tests run the built-in components, with reference checks of the routed sum, the grouped_mm path and the single cast, and a check that the layer builds the implementations the config names.
Checkpoint files name an MoE layer's tensors the way the target family does, while modules keep a native layout (stacked [E, out, in] experts). checkpoint.py replaces sgl-project#811's state_dict.py converters with layouts registered by name: the preset picks one (moe_layout overrides it), only that layout runs (sgl-project#811's converters all ran on every state dict), and it maps one MoE layer at a time. - checkpoint.py: CheckpointLayout(to_file, from_file), the LAYOUTS registry with the "native" layout (files keep module names), and to/from_checkpoint_state_dict(state, owner), where owner is the draft model or its config; dense drafts pass through unchanged. - init.py goes: WarmStartPlan and select_target_experts have no user in this PR. - config.py: MoEConfig.layout (draft JSON key moe_layout, preset default); an unknown layout fails when the config resolves. - families/__init__.py: where target families register a preset and a layout; core modules never import it. - Every file read and write converts through the draft's layout: FSDPTrainingBackend save/load, trainer resume, warm start, AutoDraftModel.from_pretrained (MoE files are read memory-mapped and regrouped, which HF from_pretrained cannot do), materialize_draft and the HF exporter. to_sglang (EAGLE3 only) goes back to main's version. - __init__.py: the public API is the contracts, the registration functions, the layer and its hooks, and the checkpoint boundary; the registries and build functions stay in their modules. - tests: the layout config keys, a per-expert test layout's round trip (with layer-prefix disambiguation), owner resolution, the native identity, and a from_pretrained round trip through the layout.
sgl-project#811 added the moe/* metrics inside the DFlash and DSpark strategies' forward_loss and collected the auxiliary loss without adding it to the objective. - strategies/base.py: DraftTrainStrategy.finalize_step_output adds the draft's summed MoE auxiliary loss to whichever term backpropagates (loss, or the loss_terms numerator scaled by this micro-batch's denominator, the weighting the objective gets) and the layer-averaged moe/* metrics; drafts without MoE layers get their output back unchanged. _moe_metrics and its two call sites go. - controller.py: TrainerCore calls it after forward_loss on every training micro-step; evaluation calls forward_loss alone, so eval losses exclude the auxiliary term. - test_dflash_host_syncs.py: the optimizer-boundary test's stand-in strategy subclasses DraftTrainStrategy, the type TrainerCore takes. - tests: the aux loss reaches the gradient with and without loss_terms, the moe/* metrics reach a strategy with no MoE code, and a dense step's output is untouched.
- moe/DESIGN.md: rewritten for the framework core: the package layout, the rules (core never imports families, every choice is explicit, a new family only adds files), and the contracts: detection keys on the preset, one TrainingState loop, deferred updates, the accumulation order, attribute names, naming converted at the file boundary, and model-level terms for every strategy. sgl-project#811's notes that still hold stay: the Megatron-Core split, why a preset per target family (serving, latency), and the extension points, including experts backends and shared experts. - customization.md: drafts opt in by naming a moe_preset; architecture and training-only keys, losses and metrics without strategy code, checkpoints and exports through the layout, how new families and components register. - training/DESIGN.md: DraftTrainStrategy owns finalize_step_output, which TrainerCore calls after forward_loss.
sgl-project#811's router, routed-expert and shared-expert contracts and their registries stay; this adds the built-in implementations, so a draft that names a preset builds a working layer and the tests run real code. - router.py: TopKRouter, registered as "topk", picks the top-k on the balance controller's selection scores and combines with the raw scores, all in fp32; a softmax score function is registered and get_score_function goes (SCORE_FUNCTIONS.get does the same). Routers implement TrainingState and receive tokens_per_sample. - experts.py: GroupedExperts, registered as "grouped", holds the routed experts as three stacked [E, out, in] parameters with sorted-segment dispatch (sorted_loop, or torch._grouped_mm on CUDA) and returns a fresh fp32 accumulator, which the RoutedExperts contract now requires. - shared.py: SwiGLUSharedExpert, registered as "swiglu". Both expert contracts implement TrainingState. - layer.py: MoELayer adds the shared expert into the fp32 routed sum, casts once, and passes tokens_per_sample to the router. - tests: the router, experts and shared-expert stubs go; the layer tests run the built-in components, with reference checks of the routed sum, the grouped_mm path and the single cast, and a check that the layer builds the implementations the config names.
Checkpoint files name an MoE layer's tensors the way the target family does, while modules keep a native layout (stacked [E, out, in] experts). checkpoint.py replaces sgl-project#811's state_dict.py converters with layouts registered by name: the preset picks one (moe_layout overrides it), only that layout runs (sgl-project#811's converters all ran on every state dict), and it maps one MoE layer at a time. - checkpoint.py: CheckpointLayout(to_file, from_file), the LAYOUTS registry with the "native" layout (files keep module names), and to/from_checkpoint_state_dict(state, owner), where owner is the draft model or its config; dense drafts pass through unchanged. - init.py goes: WarmStartPlan and select_target_experts have no user in this PR. - config.py: MoEConfig.layout (draft JSON key moe_layout, preset default); an unknown layout fails when the config resolves. - families/__init__.py: where target families register a preset and a layout; core modules never import it. - Every file read and write converts through the draft's layout: FSDPTrainingBackend save/load, trainer resume, warm start, AutoDraftModel.from_pretrained (MoE files are read memory-mapped and regrouped, which HF from_pretrained cannot do), materialize_draft and the HF exporter. to_sglang (EAGLE3 only) goes back to main's version. - __init__.py: the public API is the contracts, the registration functions, the layer and its hooks, and the checkpoint boundary; the registries and build functions stay in their modules. - tests: the layout config keys, a per-expert test layout's round trip (with layer-prefix disambiguation), owner resolution, the native identity, and a from_pretrained round trip through the layout.
sgl-project#811 added the moe/* metrics inside the DFlash and DSpark strategies' forward_loss and collected the auxiliary loss without adding it to the objective. - strategies/base.py: DraftTrainStrategy.finalize_step_output adds the draft's summed MoE auxiliary loss to whichever term backpropagates (loss, or the loss_terms numerator scaled by this micro-batch's denominator, the weighting the objective gets) and the layer-averaged moe/* metrics; drafts without MoE layers get their output back unchanged. _moe_metrics and its two call sites go. - controller.py: TrainerCore calls it after forward_loss on every training micro-step; evaluation calls forward_loss alone, so eval losses exclude the auxiliary term. - test_dflash_host_syncs.py: the optimizer-boundary test's stand-in strategy subclasses DraftTrainStrategy, the type TrainerCore takes. - tests: the aux loss reaches the gradient with and without loss_terms, the moe/* metrics reach a strategy with no MoE code, and a dense step's output is untouched.
- moe/DESIGN.md: rewritten for the framework core: the package layout, the rules (core never imports families, every choice is explicit, a new family only adds files), and the contracts: detection keys on the preset, one TrainingState loop, deferred updates, the accumulation order, attribute names, naming converted at the file boundary, and model-level terms for every strategy. sgl-project#811's notes that still hold stay: the Megatron-Core split, why a preset per target family (serving, latency), and the extension points, including experts backends and shared experts. - customization.md: drafts opt in by naming a moe_preset; architecture and training-only keys, losses and metrics without strategy code, checkpoints and exports through the layout, how new families and components register. - training/DESIGN.md: DraftTrainStrategy owns finalize_step_output, which TrainerCore calls after forward_loss.
Stacked on #798 (
kan/pr2-dsv4-dspark); retarget as the stack merges. First of two: this PR lays out the MoE FFN package (modules, contracts, config, and every trainer/checkpoint seam) with no routing math; the follow-up adds the DeepSeek-V4 implementation and the DSV4-Flash ablation arm. Together they supersede #799.Design
One configurable MoE layer with swappable components, not one block per target family (the Megatron-Core split; see
specforge/modeling/draft/moe/DESIGN.md). Family differences are a handful of small functions (score function, balancing policy, renorm/scale, shared-expert gate, activation clamp); the expensive parts (dispatch, checkpoint naming, FSDP layout, deferred balance-update timing) are shared.Config contract. Architecture keys use the target checkpoints' native HF names at the draft-JSON top level (
n_routed_experts,num_experts_per_tok,moe_intermediate_size,scoring_func,routed_scaling_factor, ...) so a draft JSON can copy them from the target'sconfig.json;moe_presetsupplies a family's defaults and any explicit key overrides it for ablations. Training-only knobs live underdflash_configasmoe_*and never touch the checkpoint.Seams wired here (so the implementation PR is components only):
Qwen3DFlashDecoderLayerbuilds its FFN throughbuild_ffn(config, dense=kernels.make_mlp): dense drafts get the kernel provider's MLP verbatim (DFlash, DFlash2 and DSpark all inherit).DFlashDraftModel._init_weightsreaches MoE bare Parameters; the model forward applies pending balance updates in training, outside activation-checkpoint regions.moe/*load metrics toStepOutput.metrics(DP-averaged and logged like any scalar).FSDPTrainingBackendsave/load, warm start,materialize_draft, and both exporters. FSDP's full-state-dict hooks index by the module's own FQNs, so the rename cannot live insidestate_dict().Tests
tests/test_modeling/test_moe.pypins the contracts with stub components: config resolution/validation/presets, registries,MoELayercomposition (shape, shared-expert add, train-only observe, hook delegation, selection bias affects choice not weights), hooks over module trees, the state-dict boundary, warm-start plans, and DFlash wiring (dense unchanged; MoE layers initialized; balance updates applied per training forward). Existing modeling/training/export/loading suites pass unchanged.Not in this PR
Any preset, score function, controller, experts backend or shared expert. Aux-loss consumption in an objective (collected, not yet wired; the first preset with an aux-loss policy adds it).