Skip to content

Intern-S2-Mobius meta-MoE support, MoE gate v2, CP attention fixes - #4835

Open
lzhangzz wants to merge 9 commits into
InternLM:mainfrom
lzhangzz:meta-moe-1
Open

Intern-S2-Mobius meta-MoE support, MoE gate v2, CP attention fixes#4835
lzhangzz wants to merge 9 commits into
InternLM:mainfrom
lzhangzz:meta-moe-1

Conversation

@lzhangzz

@lzhangzz lzhangzz commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds TurboMind support for Intern-S2-Mobius (interns2_mobius), a Qwen3.5-topology checkpoint whose routed MoE weights are stored as meta-MoE packs shared across layer groups, along with the kernel and parallel-path fixes needed to run it correctly at CP8/EP8. Verified: Mobius CP8/EP8 22/22 coherent responses, Qwen3.5-35B-A3B (TP=1, CP8/EP8, dp2/tp4/ep4) 22/22 — no regressions on non-meta models.

Meta-MoE weight wiring

  • Checkpoint layout meta_mlp.{g}.experts.* / meta_mlp.{g}.gate.weight is mapped onto model-level meta_experts.{g} packs, each loaded exactly once as a ModelWeight::meta_experts child (lmdeploy/turbomind/models/interns2_mobius.py).
  • Packs are full routed MoeWeights, prepared explicitly before the layers in ModelWeight::prepare(). Per-layer MoE weights carry only their own shared_gate/shared_expert and are wired to their pack via MoeWeight::set_meta_pack (non-owning pointer set by the loader); MoeWeight::prepare() aliases the pack's gate/expert tensors with shallow copies, and link_block() remains the only linking path.
  • Experts are sharded across EP ranks at load time.
  • Runtime (moe_ffn_layer, unified_decoder) is untouched: after prepare, each layer's moe_ffn presents the same gate/experts/block/shared_gate as a regular MoE model.

MoE gate v2 + invalid-token routing

  • New invokeMoeGate_V2 / invokeMoeGate_NoAuxTC kernels with a required token_mask: invalid tokens produce no routing entries, so the scan, expert GEMM, dispatch, and combine never touch them. This also eliminates the garbage shared_exp_id out-of-bounds write that crashed Mobius at CP8/EP8.
  • Per-token validity is computed globally at LanguageModel level: symmetric buffers for offset_q/finished, AllGather across DP ranks, and ClearTokenMaskKernel building a flat mask matching global_hidden_states (DP-local fast path when attn_dp == 1).
  • en2f cleared to -1 and routed-count pointer passed unconditionally (EP=1 included), so activation/combine never read compacted-out rows.
  • tmp_attn_ removed; attention output is a per-pass {q_count, dim} tensor.
  • New test suite under tests/turbomind/moe_gate/ (reference implementation, 20 correctness tests incl. deterministic masked-NaN routing, allocation-free benchmark harness); token_mask is a required binding input.

CP attention fixes

  • Softmax reduction: the per-CP-rank local maximum was selected by warp only, so ranks 0–3 had their maxima overwritten by ranks 4–7 at CP8. Selection now keys on the actual CP rank (reduce.cu).
  • Stream serialization: the two CpPost AllGathers (prefill/decode) are serialized on a dedicated CP stream — no event/stream sync on the main stream, and no stream/event creation at CP=1.
  • Reduce masking: finished rows are masked out of the CP softmax reduction so stale partial outputs never enter the AllReduce.

Deterministic / fused-scale FP8 GEMM fixes

  • Fused output-scale OOB write fixed in both SM90 FP8 kernels (gemm_universal_sm90_fp8_wa.h, gemm_universal_sm90_v3.h): the ungrouped row_end was effectively infinite while the scale allocation only covered the logical M extent.
  • TM_GEMM_EXPORT / TM_GEMM_IMPORT deterministic GEMM algo cache support.

Test plan

  • scripts/test_turbomind_model.py, 22 mixed-length prompts (/tmp/prompts_mixed.json):
    • Intern-S2-Mobius-FP8, CP8/EP8 on 8 GPUs: 22/22 coherent (previously crashed).
    • Qwen3.5-35B-A3B: CP8/EP8, dp2/tp4/ep4: 22/22 each.
    • Qwen3-8B/Qwen3-30B-A3B CP8/EP8 regression checks: clean.
  • python -m pytest tests/turbomind/moe_gate/test_moe_gate_v2.py -q: 20 passed.

… GEMM

MoE gate v2:
- v8 kernel specializations for e256_k8 and e2560_k8, selection mask
  widened beyond 32 items/thread
- perf: hierarchical multi-CTA scan for large-E tile counts, fused mask
  clear for E<=512, 2-row shared histogram
- pybind bridge with optional preallocated outs; pytest coverage and
  steady-state latency benchmark

Meta-MoE (InternS2Mobius):
- InternS2Mobius Python model owning the meta-MoE loading path
- encapsulated meta_moe prepare/alias unit in C++; LinearWeight::prepare
  idempotent for shared buffers; strided n_groups layer mapping

FP8 GEMM determinism at partial M tiles:
- fp8_wa indexed-gather producer: skip cp.async ZFILL for out-of-range
  slots at TILE_M=8
- fp8_wa/v3 fused epilogues: clamp per-row output-scale stores to
  global M for ungrouped GEMM (was INT_MAX, OOB past scales alloc)
MoeBuilder was constructed without the EP group, so every rank baked
ep_size=1 into the MoE weight config and the donor loaded all experts
while the runtime indexed rank-local slices — EP=8 produced garbage.
Pass ep=text_model._ep and build the donor's local slice via m.range().

alias_routed_moe iterated the global expert_num and named dst children
with 0-based local indices, but MoeWeight::expert() looks up
local_expert_offset()+i. Iterate num_local_experts() and name children
with the global expert index; assert donor/dst EP rank match.

Verified: Mobius coherent at TP8/EP8 and CP8/EP8 (was gibberish).
For CP > WarpCnt (4), each warp iterates over multiple CP ranks
(cp_i = warp_id + i * WarpCnt), but frag_M was selected by warp id
alone, so ranks 0..WarpCnt-1 had their local max overwritten by a
later rank's max. The wrong maximum was then used to rescale the
rank's partial attention output before the all-reduce, corrupting
outputs for any CP > 4 (deterministic garbage at CP=8).

Select frag_M only from the iteration whose cp_i equals this rank.
In mixed prefill/decode passes the two CpPost AllGathers of a CP group
could run concurrently on different streams (prefill on the aux stream,
decode on the main stream). Concurrent collectives on one communicator
are unordered and can pair inconsistently across ranks.

CpPost now brackets each gather with events against the
producing/consuming stream and runs the AllGather itself on a dedicated
cp_stream owned by CpPostContext, so all gathers of the group serialize
in host call order on every rank while prefill/decode compute keeps
overlapping. The stream and events are only created when cp_size > 1,
and CpPost asserts the stream exists.
Build a per-token validity mask and use it end to end:

- attention reduce: mask finished rows in the CP softmax reduction so
  stale partial outputs never enter the AllReduce
- attention layer owns a DP-local per-token validity mask derived from
  q_offsets and finished, cleared/set once per forward pass
- LanguageModel builds the mask globally: symmetric buffers for
  offset_q/finished, AllGather across DP ranks, and a
  ClearTokenMaskKernel (invokeBuildTokenMask) producing the flat global
  mask matching global_hidden_states
- MoE gate (both invokeMoeGate_V2 and invokeMoeGate_NoAuxTC) takes a
  required token_mask; invalid tokens produce no routing entries, so
  scan, expert GEMM, dispatch and combine never touch them. This also
  removes the garbage shared_exp_id out-of-bounds write seen with
  Mobius CP8/EP8
- moe_ffn_layer: clear en2f to -1 and pass num_valid_tokens
  unconditionally (EP=1 included)
- unified_attention_layer: drop tmp_attn_ entirely; attn is now a
  per-pass {q_count, dim} tensor
- tests: token_mask is a required binding input, harness defaults to
  all-true; add deterministic masked-NaN routing test; benchmark
  preallocates the mask outside the timed loop
The checkpoint stores meta-MoE packs once at model level; the weight
tree now mirrors that instead of cloning them into every layer:

- ModelWeight gains a meta_experts ModuleList of full routed MoeWeight
  packs, loaded once by the Python loader and prepared explicitly
  before the layers in ModelWeight::prepare()
- Per-layer MoE weights carry only their own shared_gate and are wired
  to their pack via MoeWeight::set_meta_pack (non-owning pointer, set
  by the loader through a new pybind method)
- MoeWeight::prepare() aliases the pack's routed gate/expert tensors
  with shallow tensor-sharing copies (AliasLinear/AliasFfn); link_block
  remains the only linking path
- Delete meta_moe.{cc,h} (PrepareMetaMoe, ModelHasMetaMoe,
  alias_routed_moe), the prepare fork in ModelWeight::prepare, the
  is_meta_donor/meta_group config fields, and prepare_routed_linears

Runtime (moe_ffn_layer, unified_decoder) is untouched: after prepare
each layer's moe_ffn presents the same gate/experts/block/shared_gate
as before.

Verified: Intern-S2-Mobius-FP8 CP8/EP8 22/22 coherent responses,
Qwen3.5-35B-A3B TP=1 22/22 (non-meta regression).
… config

The shape() accessor existed only for infer_meta_geometry to read the
expert count from checkpoint tensors, but cfg.num_experts already
provides it via Qwen3_5TextModel.__init__. infer_meta_groups now only
counts the meta packs with has().
Absolute tests.turbomind.moe_gate imports only resolve when the repo
root is on sys.path, which is not true under CI's pytest collection
(ModuleNotFoundError: No module named 'tests.turbomind'). The package
has __init__.py at both levels, so relative imports work under
pytest's prepend import mode regardless.
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