Skip to content

Weight Loader v2 PR4: Qwen3.5 / Qwen3-Next language paths - #1

Draft
MartinHua wants to merge 7 commits into
feat/weight-loader-v2-pr2-rebasedfrom
feat/weight-loader-v2-pr4-qwen
Draft

Weight Loader v2 PR4: Qwen3.5 / Qwen3-Next language paths#1
MartinHua wants to merge 7 commits into
feat/weight-loader-v2-pr2-rebasedfrom
feat/weight-loader-v2-pr4-qwen

Conversation

@MartinHua

@MartinHua MartinHua commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Motivation

Weight Loader v2 PR4 from sgl-project#31051 — Qwen3.5 / Qwen3-Next language paths.

Base: stacks on PR2 (sgl-project#32565). feat/weight-loader-v2-pr2-rebased is PR2's commit rebased onto current main — PR2's own branch is ~209 commits behind and targeting it renders a 1413-file diff. Rebase onto PR2 and drop the base commit once it lands. The dependency is real, not ordering: both models import their MLP from qwen2_moe.py, and this migration calls self.mlp.load_weights(...), which only exists in PR2.

Modifications

Class State
Qwen3NextForCausalLM v2, own gate
Qwen3NextForCausalLMMTP v2 via super() → parent gate
Qwen3_5ForCausalLM v2, own gate
Qwen3_5ForCausalLMMTP v2, own gate
Qwen3_5MoeForCausalLM legacy only — see (1)

auto_loader.py adds QWEN3_NEXT_GDN_STACKED_MAPPING (packed gated-delta-net, shared by both families), FusedExpertDispatch, split_submodule_weights. Qwen3.5 reuses main's QWEN3_5_KV_SCALE_MAPPER instead of re-implementing the ModelOpt kv-scale remap. qwen3_5_mtp.py forwards the mtp. branch to the draft body — itself a Qwen3_5ForCausalLM that already owns dispatch — replacing a 200-line copy of the mapping tables with a 33-line split. Legacy paths are untouched and still selected when the gate is off.

Two non-obvious points. (a) Both families inline qkv_proj/o_proj onto the decoder layer, so that layer is the only place where the split checkpoint names and the fused runtime param are both visible and must define load_weights — but AutoWeightsLoader._load_module gives a whole subtree to the first module defining it, so mlp. needs explicit re-entry or every expert key is swallowed there. (b) Qwen2MoeSparseMoeBlock.load_weights maps shared_expert.* into the fused slot itself and selects an empty dense_stacked when fusion is on; pre-renaming to mlp.experts.<slot>.* yields a key matching no expert mapping, which the block rejects (ValueError: No parameter named 'experts.8.gate_proj.weight') — i.e. every shared-expert-fusion checkpoint fails to load. v2 passes those names through unrewritten.

Accuracy Tests

18 CPU tests (base-b-test-cpu) in test/registered/unit/model_loader/test_pr4_qwen3_{next,5}_v2.py: name remapping; MTP filter/rename both directions and the MTP body/wrapper split; the trailing-dot disambiguation that stops a fused in_proj_qkvz/in_proj_ba checkpoint being rewritten onto itself with a bogus shard id; the decoder-layer MLP hand-off; shared-expert passthrough; fused-expert fan-out order; raise-on-missing-target. Plus three shared v2 invariants with no prior coverage anywhere in the repo: filter_pp_weights range/passthrough, the walker rejecting an unexpected name (the "no silent drops" contract), and ignore_unexpected_suffixes being specific rather than blanket.

Equivalence cases for both families added to PR1's state-dict harness and PR2's e2e harness as hybrid_gdn_dense / hybrid_mamba. No tiny-random fixture exists for either, so defaults differ by resolver: test/manual/…_equiv.py is cache-only and names the real 4B/80B checkpoints (skips unless cached); the registered e2e resolver downloads, so its defaults are unresolvable placeholders that skip with a message naming the override env var.

Not verified — no GPU available to the author:

  • state-dict equivalence — one command: SGLANG_WEIGHT_LOADER_V2_HYBRID_GDN_DENSE_MODEL=Qwen/Qwen3.5-4B pytest test/manual/test_weight_loader_v2_equiv.py -k hybrid_gdn_dense
  • FusedExpertDispatch now raises rather than returning a missing target; PP is filtered upstream and EP still materializes w13_weight per rank, so this should be safe but wants one EP/PP load
  • the fusion fix in (b) lives in Qwen3-Next, whose only public checkpoint is 80B, so that regression needs multi-GPU

Speed Tests and Profiling

N/A — loading only, behind an env gate that is off by default.

Checklist

  • Formatted (black 26.1.0, isort 7.0.0), py_compile clean
  • Added unit tests
  • Followed SGLang code style
  • GPU equivalence run

Open questions

  1. Qwen3_5MoeForCausalLM fused-expert routing. PR2's block loader knows only the per-expert form, but Qwen3.5 MoE checkpoints can stack all experts into one experts.gate_up_proj (w1/w3 packed on dim −2). Either FusedExpertDispatch at decoder-layer level (faithful mirror of the legacy load_fused_expert_weights, but needs loaders on both decoder layer types plus the block's _ckpt_num_experts), or un-fusing in the weight stream (cleaner, but the key spelling can't be confirmed without a real fused checkpoint). qwen3_5_mtp.py inherits the gap through the body, so one fix covers both. The class is insulated meanwhile — it overrides load_weights with no super() call.
  2. qwen3_5_text.py is affected but absent from PR4's file list. EntryClass = [Qwen3_5MoeForCausalLM, Qwen3_5ForCausalLM], delegating via self.model.load_weights(body_weights) (line 164) — a bound delegate, so it auto-enters v2; same for MiniCPM-V 4.6 (minicpmv.py:1592). Per the plan's "parent migration includes affected downstream paths" invariant these want env-off/env-on routing smoke. Should the file and the 187 denominator be updated?
  3. Once this lands, qwen3_5 / qwen3_next should leave PROTECTED_MODEL_PREFIXES in test_weight_loader_v2_model_contracts.py.

Tracking: sgl-project#31051

MartinHua added 7 commits July 31, 2026 23:53
Adds the Qwen-family dispatch primitives the PR2 auto_loader does not carry
(packed GDN stacked mapping, fused-expert fan-out, Qwen3.5 name
normalization) and migrates qwen3_next.py behind SGLANG_ENABLE_WEIGHT_LOADER_V2.

Qwen3-Next inlines qkv_proj/o_proj onto the attention decoder layer rather
than a nested attention module, so that layer owns the stacked dispatch and
must re-enter the MoE/dense block's own loader explicitly: the walker hands a
whole subtree to the first module defining load_weights, so mlp weights would
otherwise never reach expert dispatch. split_submodule_weights makes that
hand-off reusable.

FusedExpertDispatch raises on a missing runtime target instead of returning it,
matching the PR2 ExpertParamsDispatch contract and the plan's "missing targets
must fail before execution" invariant.

Refs: sgl-project#31051
Adds mapping/dispatch coverage for the PR4 Qwen3-Next path and forwards the
loaded-name set from the MTP wrapper so it matches the base loader contract
asserted by test_wrapper_base_dispatch.

The load-bearing case is the attention-layer hand-off: with a plain stacked
dispatch on the decoder layer, every mlp.* key is swallowed by the layer and
silently dropped instead of reaching expert dispatch. Verified red against that
implementation and green with the explicit hand-off. The GDN case pins the
trailing-dot disambiguation that keeps a fused in_proj_qkvz/in_proj_ba
checkpoint from being rewritten onto itself with a bogus shard id.

Refs: sgl-project#31051
The v2 preprocessing generator rewrote mlp.shared_expert.* to the fused routed
slot before the walker ran. That was correct against the earlier PR2 draft, where
the block loader did not handle shared experts, but the current
Qwen2MoeSparseMoeBlock.load_weights maps shared_expert.gate_proj/down_proj/up_proj
straight into slot _ckpt_num_experts itself.

Renaming first produces experts.<slot>.* , which matches no entry in the block's
expert mapping (make_expert_params_mapping only covers ids < num_experts), so
load_moe_sparse_block_weights rejected it:

  ValueError: No parameter named 'experts.8.gate_proj.weight' in
  Qwen2MoeSparseMoeBlock.

i.e. every shared-expert-fusion checkpoint failed to load under v2. Dropping the
remap lets the block's own mapping match. The block also selects an empty
dense_stacked when fusion is on, so gate_proj must stay un-rewritten for it to
route correctly. The legacy loader keeps its own rename and is unaffected.

Refs: sgl-project#31051
Mechanical cleanups from reviewing the three preceding commits; no behavior
change to the Qwen3-Next v2 path (fan-out order and the shared-expert
passthrough are unchanged and still covered by the existing tests).

- Drop QWEN35_GDN_STACKED_MAPPING, QWEN35_STACKED_MAPPING and
  normalize_qwen35_weight_name. They were ported ahead of qwen3_5.py and had no
  reference anywhere, not even in tests; they belong in the commit that migrates
  that model. FusedExpertDispatch stays: it is a named PR4 deliverable and is
  exercised by unit tests.
- FusedExpertDispatch no longer resolves its loader via
  getattr(param, "weight_loader", default_weight_loader). default_weight_loader
  accepts only (param, tensor), so the fallback could never satisfy the
  shard_id/expert_id call and would have raised TypeError instead of degrading.
  Fused expert params always carry a FusedMoE loader, so access it directly and
  fail loudly, matching StackedParamsDispatch in the same module.
- fan_out_to_experts becomes a protected instance method: it was static yet
  every caller passed self.num_experts. Its helpers are keyword-only, and the
  load_with_stacked_dispatch call sites pass mapping by keyword, per the
  keyword-argument convention in general-code-style.
- iter_qwen3_next_checkpoint_weights now requires params_dict. It was optional
  and defaulting to None silently disabled the unit-scale verification, which is
  a correctness guard callers should not be able to skip.

Refs: sgl-project#31051
Migrates Qwen3_5ForCausalLM behind SGLANG_ENABLE_WEIGHT_LOADER_V2, mirroring the
Qwen3-Next structure: the gated-delta-net mixer owns packed GDN dispatch, and the
attention decoder layer owns stacked qkv dispatch plus an explicit hand-off of
mlp.* to the MLP block's own loader (Qwen3.5 inlines qkv_proj onto the layer, and
the walker gives a whole subtree to the first module defining load_weights).

iter_qwen3_5_text_checkpoint_weights reuses main's QWEN3_5_KV_SCALE_MAPPER rather
than re-implementing the ModelOpt kv-scale remap by hand, and drops the MTP and
vision streams, which own their own loaders. Shared-expert keys are passed through
unrewritten so the MoE block can map them into the fused slot.

Qwen3_5MoeForCausalLM is untouched: it fully overrides load_weights with no
super() call, so it stays on the legacy path. Its fused-expert checkpoint format
(experts.gate_up_proj stacked across experts) needs routing that the MoE block
loader does not yet provide, so it is deliberately left for a follow-up rather
than guessed at.

Refs: sgl-project#31051
Migrates Qwen3_5ForCausalLMMTP behind SGLANG_ENABLE_WEIGHT_LOADER_V2 by splitting
the checkpoint instead of duplicating mapping tables: the wrapper loads its own fc
and pre-fc norms, and forwards the rest of the mtp.* branch to the draft body,
which is a full Qwen3_5ForCausalLM and already owns stacked / GDN / expert
dispatch. That replaces a 200-line copy of those tables with a 33-line split, and
means the draft automatically picks up whatever the body's loader supports.

Names come back from the body re-prefixed with "model." so they match this
module's own parameter names. embed_tokens and lm_head are not checkpoint-loaded
on this path: they are installed from the target by set_embed_and_head /
set_lm_head_from_target.

The draft branch is selected by the "mtp." prefix rather than a bare "mtp"
substring, so a malformed key cannot be forwarded to the body with a stale path.
The legacy loader is unchanged and still selected when the env gate is off.

Fused-expert checkpoints reach the body's MoE block loader, which does not yet
handle the stacked experts.gate_up_proj form — that gap is shared with
Qwen3_5MoeForCausalLM and is deliberately left for a follow-up.

Refs: sgl-project#31051
…ences

Comparing PR4 against the two reference points — PR1's state-dict equivalence
harness and PR2's qwen3.py dense migration — surfaced coverage that neither PR4
nor the repo had.

Shared v2 invariants, none of which were tested anywhere before:
- filter_pp_weights drops layers outside [start_layer, end_layer) and passes
  through keys with no parseable layer index.
- The walker rejects an unexpected checkpoint name instead of dropping it, which
  is the "no silent drops" contract v2 exists for.
- ignore_unexpected_suffixes is specific rather than blanket: a declared suffix is
  ignored, an undeclared one still raises. Qwen3.5 depends on this for .kv_scale.

Equivalence cases for both hybrid families, which PR4 had none of. No tiny-random
fixture is published for either, so the two harnesses differ deliberately: the
manual one resolves cache-only and can name the real 4B/80B checkpoints (it skips
unless cached), while the registered e2e resolver downloads, so its defaults are
unresolvable placeholders that skip with a message naming the override env var.

Also documents why qwen3_next does not list ".kv_scale" in
ignore_unexpected_suffixes while qwen3_5 does: the qwen3_next generator already
resolves every homeless "*_scale" key, so none reach the walker. Adding the suffix
there would be inert, and the asymmetry is a consequence of the two generators
rather than an oversight.

Refs: sgl-project#31051
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