Skip to content

exp - feat(serving): SGLang MoE DSpark draft loader for Kan's MoE drafter (#811/#812), normalizer arch name and equivalence gate (stacked on #931) - #940

Draft
yushengsu-thu wants to merge 9 commits into
sgl-project:mainfrom
yushengsu-thu:feat/dspark-moe-sglang-loader
Draft

yushengsu-thu wants to merge 9 commits into
sgl-project:mainfrom
yushengsu-thu:feat/dspark-moe-sglang-loader

Conversation

@yushengsu-thu

@yushengsu-thu yushengsu-thu commented Oct 6, 2026 •

Copy link
Copy Markdown

Serving-side companion to Kan's MoE drafter PRs (#811 skeleton + #812 DeepSeek-V4 MoE FFN, @sherlockwu). Kan's PRs add the MoE FFN on the training side and ship the DeepSeek-V4-Flash dense-vs-MoE DSpark ablation arm, but SGLang has no draft class that can load the MoE export, so the MoE arm could not be served or benchmarked. This PR adds the missing loader (as a patch, pending SGLang upstream), the export normalizer wiring, and an equivalence gate against Kan's MoELayer, so the #812 arm can be measured end to end. The serving numbers below are the first real acceptance-length results for Kan's MoE arm.

Stacked on #931 (moe-stack-rebased = main + Kan's #811/#812). This branch is #931 plus one commit; review that commit only (git diff fd0745e3..HEAD). Once #811/#812/#931 land this PR shrinks to that commit.

Problem

SGLang's stock Qwen3DSparkModel has a dense MLP. Loading a DSpark export whose FFN is a DeepSeek-style MoE (moe_preset: deepseek_v4, the MoE arm of Kan's #812) silently drops every expert, gate and shared-expert weight: the server starts, CUDA graphs capture, but the drafter is random (acceptance length 1.006 on gsm8k after a full two-epoch run). Nothing on the serving side could load the MoE arm, so it could only be measured training-side.

What this adds

  • patches/sglang/v0.5.18/dspark-moe-draft.patch (same convention as spec-capture.patch; apply with git apply on top of it):
    • new sglang/srt/models/dspark_moe.py with Qwen3MoEDSparkModel: DSparkDraftModel with each layer's dense MLP replaced by DraftMoEFFN (gate + stacked routed experts + ungated shared expert). The routing mirrors Kan's specforge/modeling/draft/moe (DeepSeek-V4 MoE FFN for DFlash-family drafters + DSV4-Flash ablation arm #812) 1:1 in fp32: sqrtsoftplus scores, aux-loss-free selection bias (gate.bias), renormalized combine weights × routed_scaling_factor, SwiGLU clamp, optional group-limited selection. Experts are loaded from the official per-expert naming into stacked [E, out, in] weights and run through torch._grouped_mm (loop fallback). Draft is replicated (TP1 draft worker), no EP.
    • strict loading in dflash.py / dspark.py: checkpoint weights that map to no parameter, or backbone / markov-head parameters the checkpoint leaves untouched, now raise ValueError instead of serving uninitialised. Mislabelling the MoE export as the dense class fails at load (985 checkpoint weight(s) do not map to any parameter).
  • scripts/gates/normalize_dflash_export.py: writes architectures: ["Qwen3MoEDSparkModel"] when the export has n_routed_experts > 0; refuses MoE DFlash/DFlash2 exports (no MoE-capable serving class exists for them yet). Two tests added in tests/test_scripts/test_gate_orchestration.py.
  • scripts/gates/check_dspark_moe_sglang_equivalence.py: builds SpecForge's MoELayer, converts it through to_checkpoint_state_dict, loads it into DraftMoEFFN via the serving stacking logic, and compares routing (identical top-k and weights) and outputs for the small config and the real 64-expert / top-6 / 2048 sizes on both dispatch paths; also checks that a truncated expert set is rejected.
  • Recipe doc: "Serving the MoE arm" paragraph in docs/recipes/deepseek-v4-flash-dspark-disaggregated.md.

Validation

  • Equivalence gate: PASS on B200 (bit-exact outputs, identical routing) for small and DSV4-preset sizes, grouped_mm and loop.

  • End-to-end with Kan's DeepSeek-V4 MoE FFN for DFlash-family drafters + DSV4-Flash ablation arm #812 DSV4-Flash MoE arm (SGLang v0.5.18 + spec-capture + this patch, --speculative-algorithm DSPARK, greedy, 512 tokens): strict load lists 64 experts / top-6 / shared / sqrtsoftplus / bias / ×1.5 / clamp 10 with nothing skipped; CUDA graph capture bs 1–8 fine; generation correct.

    MoE checkpoint gsm8k accept (c=1) mt-bench accept (c=1) gsm8k accept (c=8) tok/s (c=1)
    step 128 1.118 1.095 (c=4) 1.117 86
    step 1024 1.30 1.34 1.31 94
    dense step 1024 (reference, stock class) 1.38 1.40 – 117

    Acceptance grows with training in step with the dense arm (previously stuck at 1.006 with the dense class), and matches the training-side per-position accuracy estimate within ~0.08.

  • Local: python -m pytest tests/test_scripts/test_gate_orchestration.py tests/test_runtime/test_package_architecture.py → 30 passed; black / isort clean on the changed Python files.

Follow-ups

  • Send Qwen3MoEDSparkModel and the strict loader checks to SGLang upstream (the patch targets v0.5.18; upstream dspark.py / dflash.py have moved, so it needs a rebase), then drop the patch here and keep only the normalizer change.
  • MoE-capable DFlash / DFlash2 serving classes (the exp - feat(moe): qwen3 preset and the Qwen3.8-27B DFlash2 MoE ablation arm (stacked on #811/#812) #930 Qwen3.8-27B DFlash2 MoE arm is training-side only until then).
  • specforge benchmark cannot render DeepSeek-V4 prompts (no Jinja template); the numbers above used a script around the checkpoint's Python encoder.

🤖 Generated with Claude Code

sherlockwu and others added 9 commits October 5, 2026 12:50
… 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>
…ation arm

Fills the MoE skeleton with the DeepSeek-V4 recipe and ships the MoE arm of the
dense-vs-MoE DSpark ablation for DeepSeek-V4-Flash.

- topk_router.py: "topk" router; softmax / sigmoid / sqrtsoftplus score
  functions; optional group-limited selection (DeepSeek top-2 group scores).
  Routing math in fp32; combine weights renormalized then scaled.
- noaux_tc.py: aux-loss-free balancing — fp32 selection bias (kept fp32 through
  module dtype casts) moved by a sign controller on all-reduced expert loads,
  applied from the model forward outside checkpoint regions. Stored in
  checkpoints as gate.bias (the DeepSeek native key SGLang maps onto
  e_score_correction_bias) via a state-dict converter.
- grouped_experts.py: experts as stacked [E, out, in] w1/w2/w3 (FSDP tracks 3
  tensors, grouped GEMMs read them directly); sorted-segment dispatch with a
  torch._grouped_mm path (no host sync) or the portable per-expert loop
  (dflash_config.moe_dispatch). Converter keeps files in the official
  experts.{i}.w{1,2,3}.weight naming. Experts and gate init with the draft's
  initializer_range, like the dense MLP they replace.
- swiglu_shared.py: one ungated SwiGLU shared expert with the V4 clamp.
- presets.py: "deepseek_v4" = sqrtsoftplus + noaux_tc + renorm x1.5 + one
  shared expert + swiglu_limit 10.
- init.py: apply_warm_start() seeds a layer from a target layer's dequantized
  tensors (selected experts, gate rows/bias, shared expert) through the
  checkpoint-naming boundary. Reading/dequantizing DSV4-Flash's fp4 experts is
  left to the target tooling.
- configs/deepseek-v4-flash-dspark-moe.json: the dense DSpark config plus
  moe_preset deepseek_v4, 64 routed + 1 shared experts, top-6, width 2048
  (activated FFN width ~= the dense 12288). The disaggregated recipe mirrors
  the dense one exactly except the draft JSON and run/store names.
- Tests: recipe/config resolution, router weights and group-limited routing,
  deferred bias updates and fp32 buffer, dense per-token reference forward,
  grouped-GEMM vs loop parity (CUDA), official-naming round trips at layer and
  model level, warm start, DFlash training/balancing integration.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- MoEConfig.serving_fields(): the resolved recipe in the DeepSeek HF config
  vocabulary (scoring_func, topk_method=noaux_tc, routed_scaling_factor,
  n_group/topk_group, norm_topk_prob, swiglu_limit when enabled, ...).
  export --to hf writes them into config.json so a serving engine needs no
  knowledge of SpecForge presets.
- AutoDraftModel.from_pretrained: HF assigns tensors by key and cannot regroup
  per-expert files into the stacked module layout (and refuses an explicit
  state_dict next to a path), so for MoE configs read the safetensors, convert
  through from_checkpoint_state_dict, and load into a freshly built module.
  Warm starts from HF export dirs go through this path.
- Tests: serving fields; an end-to-end export -> config.json -> from_pretrained
  round trip on a tiny MoE DFlash draft.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
dflash_config.moe_aux_loss_coeff > 0 adds DeepSeek-V3's complementary
sequence-wise balance loss (coeff * sum_e f_e P_e over the micro-batch) built
from the router's differentiable scores; DFlash/DSpark strategies add it to
the training loss and it appears as moe/aux_loss.

Why: the bias controller only reorders selection. A from-scratch DFlash-family
drafter feeds the router near-identical mask-token embeddings early on, so
every token routed to the same experts (load_max_ratio pinned at E/k, ~85% of
experts unused through step 100) while the gate logits grew faster than the
bias could follow. The differentiable term bounds the logit gaps so
input-driven routing can emerge.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- moe/deepseek_v4_target.py: dequantize one target layer's ffn.* tensors
  (packed fp4 e2m1 experts with per-32 ue8m0 scales, fp8 e4m3 shared expert
  with 128x128-block scales, bf16 gate, fp32 noaux bias) into the official
  naming apply_warm_start() consumes; hash-routed layers are rejected.
- scripts/warm_start_moe_drafter.py: build the draft from its config, seed each
  MoE layer from a chosen target layer (identity expert mapping when the
  shapes match, strided subset otherwise), record provenance and the serving
  fields, and write an HF dir usable as model.draft_checkpoint_path.
- Tests for the fp4/fp8 conventions and the layer dequant/naming.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- AutoDraftModel.from_pretrained (MoE path): honor output_loading_info, which
  the warm-start loader passes; read safetensors memory-mapped and drop the
  regrouped state once loaded.
- warm start: for drafts with a native module layout, read and regroup the
  files directly instead of materializing a second full model per rank, and
  let ranks take turns loading. Eight ranks each building a 65 GB CPU model
  and then loading another 65 GB state + model exceeded the node's RAM and
  got the trainer OOM-killed on the 256-expert warm-started run.
- Test: warm_start_draft_model round-trips a tiny MoE export.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…FSDP

dflash_config.moe_freeze_experts keeps the routed experts fixed (router,
shared expert and the rest of the draft still train). RoutedExperts opts into
FSDP replication when fully frozen, and the backend's frozen-module scan now
honors that opt-in next to lm_head/embed_tokens.

Why: with DeepSeek-V4-Flash's 256x2048 experts (32.7B params) sharded, every
micro-batch re-gathered ~65 GB of weights and reduce-scattered their grads
(~3 TB/step, 46 s/step on 8 B200s), and at the drafter's LR (6e-4) AdamW
would have overwritten the warm-started experts within ~100 steps anyway.
Frozen + replicated: no expert communication, dense-like step time, and the
pretrained experts are what gets served.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…name and equivalence gate

SGLang's stock Qwen3DSparkModel has a dense MLP: loading a DSpark export whose
FFN is a DeepSeek-style MoE (moe_preset drafts, sgl-project#812) silently drops every
expert weight, the server starts, and the drafter serves random MLPs
(acceptance length ~1.0). Nothing on the serving side could load the MoE arm.

- patches/sglang/v0.5.18/dspark-moe-draft.patch: adds
  sglang/srt/models/dspark_moe.py with Qwen3MoEDSparkModel (the DSpark decoder
  with each dense MLP replaced by the DeepSeek-V4 routing recipe: sqrtsoftplus
  scores, aux-loss-free selection bias, renormalized combine weights x1.5, one
  ungated shared expert, SwiGLU clamp; experts loaded as stacked grouped-GEMM
  weights), and makes the DFlash/DSpark loaders reject checkpoints whose
  weights do not map onto the class or leave backbone / markov-head parameters
  unloaded, instead of serving uninitialised modules.
- scripts/gates/normalize_dflash_export.py: write
  architectures: ["Qwen3MoEDSparkModel"] when the export has
  n_routed_experts > 0; refuse MoE DFlash/DFlash2 exports, which have no
  MoE-capable serving class.
- scripts/gates/check_dspark_moe_sglang_equivalence.py: checks SGLang's
  DraftMoEFFN against SpecForge's MoELayer through the checkpoint naming
  boundary (small config and the real 64-expert/top-6/2048 sizes, grouped_mm
  and loop dispatch, routing identical, outputs bit-exact).
- tests: normalizer cases for the MoE architecture name and the rejection.
- docs: serving paragraph in the DeepSeek-V4-Flash DSpark recipe.

The patch is the interim home; the class and the strict loader checks belong
in SGLang upstream, after which the patch goes away and only the normalizer
change stays.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@yushengsu-thu yushengsu-thu changed the title exp - feat(serving): SGLang MoE DSpark draft loader patch, normalizer arch name and equivalence gate (stacked on #931) exp - feat(serving): SGLang MoE DSpark draft loader for Kan's MoE drafter (#811/#812), normalizer arch name and equivalence gate (stacked on #931) Oct 6, 2026

This branch has not been deployed

No deployments
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