Skip to content

feat(qwen35-moe): emit fused com.microsoft::QMoE for int4 A3B decode - #451

Open
justinchuby wants to merge 2 commits into
mainfrom
squad/qwen35-qmoe-export
Open

feat(qwen35-moe): emit fused com.microsoft::QMoE for int4 A3B decode#451
justinchuby wants to merge 2 commits into
mainfrom
squad/qwen35-qmoe-export

Conversation

@justinchuby

@justinchuby justinchuby commented Aug 3, 2026

Copy link
Copy Markdown
Member

Goal

Realize Qwen3.5/3.6-MoE (35B-A3B) A3B decode speed by exporting a fused
com.microsoft::QMoE node per MoE layer instead of the dense_fallback
representation (per-expert MatMulNBits MLPs + Equal/ReduceSum masking).

The dense_fallback graph computes all 256 experts every token (full 35B HBM
weight traffic). onnx-genai's CUDA QMoE kernel already does sparse top-k=8
decode
(reads only k experts' weights, ~32x less MoE weight traffic), so the
only change on the critical path was the mobius export.

Changes

  • _supported_qmoe_quantization (components/_moe.py): accept
    quant_method="olive" (blk32 int4 integer-affine) alongside gptq/awq, and
    require a power-of-two block_size >= 16 (the CUDA QMoE kernel constraint) so
    non-conforming configs fall back to the portable dense representation rather
    than emitting an unrunnable node.
  • preprocess_olive_weights (_weight_utils.py): preserve all leading dims
    when reshaping .qweight, so fused expert-major tensors
    [E, N, packed_K] -> [E, N, n_blocks, blob_size] feed the QMoE repacker. 2-D
    linears reshape identically ([N, n_blocks, blob_size]) — no behavior change.
  • Qwen35MoECausalLMModel.preprocess_weights (models/qwen35.py): guard the
    per-expert un-fuse behind use_qmoe. When QMoE is supported, keep the fused
    expert-major weights and route through pack_qmoe_expert_weights(..., target_moe_path=".mlp") to emit one QMoE node per MoE layer instead of
    un-fusing. Mirrors DeepSeek-V3's use_qmoe path.
  • deepseek.py: use the shared _supported_qmoe_quantization predicate for
    use_qmoe so the repacked weights and the emitted graph never disagree (DRY;
    avoids drift from the new block_size guard).

Correctness

Byte-equivalent to dense_fallback: identical (q - zero_point) * scale int4
affine, identical softmax-top-k routing, identical SwiGLU (swiglu_fusion=2,
gate = first half of fc1, up = second half). This is a pure layout repack —
no requantization, no accuracy change.

Tests (all green via the conda onnx env)

  • test_int4_olive_moe_emits_expert_major_qmoe: Olive MoE emits a single fused
    QMoE node, zero per-expert MatMulNBits.
  • test_olive_group_size_not_power_of_two_falls_back_to_dense: non-pow2
    block_size -> dense fallback (kernel would reject).
  • test_olive_fused_expert_packing_is_byte_exact_vs_dense: fused Olive expert
    weights (asymmetric int4, per-block zero-points) repack to the QMoE ABI with
    dequant atol=0, and a loop-over-experts SwiGLU forward matches the dense
    per-expert reference within fp32 tolerance.
  • models/qwen35_test.py: Olive preprocess_weights packs fc1/fc2 QMoE
    params that bind to real model parameters and leak no per-expert tensors; the
    unquantized path still un-fuses to the dense fallback.

ruff clean; _moe_test.py, qwen35_test.py, deepseek_test.py,
_qwen35_mtp_test.py, _weight_utils_test.py all pass (132 tests).

Structural proof (tiny 4-layer Qwen3.5-MoE, E=16/k=4, blk32 int4 Olive)

op QMoE-mode dense_fallback
com.microsoft::QMoE 4 0
MatMul 37 229
Equal (mask) 0 64
ReduceSum (mask) 0 64
TopK 0 4

One QMoE node per MoE layer; the per-expert MatMul storm and Equal/ReduceSum
masking are eliminated. Extrapolates to the real 35B: 40 MoE layers -> 40 QMoE
nodes, ~20,480 per-expert expert MatMulNBits removed.

35B regen status (follow-up)

The existing dense artifact was produced via mobius float export ->
Olive ONNX-graph onnxkquantquantization (q4_k_m). This PR implements the
mobius olive-import QMoE path (quant_method="olive", pre-packed
.qweight). A full 35B QMoE artifact therefore needs an Olive-RTN-quantized
Qwen3.6-35B HF checkpoint (fused expert .qweight/.scales/.qzeros with
quantization_config.quant_method="olive"), which is not on disk yet — the only
local copy is the float bf16 source. The tiny proof + byte-exact tests validate
the path end to end; the 35B regen is a compute step (RTN-quantize the 35B, then
re-export) that a measurement pass can pick up.

Please review and merge — I do not self-merge mobius.


Update — dense-fallback → QMoE graph rewrite (reuse existing int4 weights)

To get a measured 35B QMoE number without re-quantizing a 70GB HF checkpoint,
this PR also adds an ONNX-to-ONNX rewrite that fuses the existing
dense_fallback artifact's per-expert MatMulNBits storm into QMoE nodes,
reusing the int4 weights byte-for-byte.

New: mobius.rewrite_rules.fuse_dense_moe_to_qmoe

rewrite_rules/_qmoe_fusion.py — a topology-driven pass (anchored on each MoE
TopK) that recognises the dense_fallback subgraph and rewrites it to one
com.microsoft::QMoE node per layer:

  • router: gate MatMulNBits logits are Cast to float32 and passed as
    router_probs with normalize_routing_weights=1 (matches TopKGate:
    Softmax(TopK(logits))). Walks through an optional Cast between the gate and
    TopK (the merged graph variant).
  • experts: per expert, fc1 = concat(flatten(gate_w), flatten(up_w)) and
    fc2 = flatten(down_w), stacked expert-major. Packed uint8 weights and
    zero-points are copied bit-identically (same low-nibble-first, K-contiguous
    packing as the QMoE kernel — verified against qmoe.rs); float16 scales are
    upcast to the float32 the kernel requires (value-exact, lossless).
  • attrs: activation_type=swiglu, swiglu_fusion=2, k=8,
    expert_weight_bits=4, block_size=32, quant_type=int.
  • shared expert (Qwen2-MoE shared_expert + shared_expert_gate) is left
    dense; the final Add(routed_sum, shared) is rewired onto the QMoE output.
    Dead dense-routing nodes and now-unused initializers are removed.

Tests (rewrite_rules/_qmoe_fusion_test.py, 6 cases)

On a tiny Qwen35-MoE-shaped dense graph (incl. a shared expert):
E x3 MatMulNBits storm collapses to 1 QMoE node (TopK/Softmax/Equal/
ReduceSum removed); packed weights + zero-points are bit-identical to the
concatenated per-expert tensors; scales are a lossless float32 upcast; and a
reference forward on the rewritten graph matches the dense forward within fp32
tolerance.

Applied to the real 35B artifact (A/B, no requant)

Rewrote qwen36-35b-a3b-artifacts/{decoder,merged}/model.onnx into
qwen36-35b-a3b-qmoe-artifacts/ (dense artifact left untouched):

graph nodes (before → after) MatMulNBits TopK QMoE
decoder 125,355 → 2,259 31,111 → 391 40 → 0 0 → 40
merged 125,436 → 2,300 31,111 → 391 40 → 0 0 → 40

The MatMulNBits drop is exactly 40 x 256 x 3 = 30,720 (the full expert
storm); the remaining 391 are the router gate + shared-expert projections. Graph
integrity re-checked (0 dangling inputs, 0 topo violations). Byte-exactness
re-verified on real experts (layer 0, experts 0/1/127/255): fc1/fc2 weights and
zero-points bit-identical, scales fp16→fp32 lossless.

Qwen3.5-MoE (e.g. Qwen3.6-35B-A3B) exported its MoE as dense_fallback: every
routed expert un-fused into per-expert MatMulNBits MLPs + Equal/ReduceSum
masking, so native decode computed all 256 experts/token instead of the top-k=8
active. onnx-genai's CUDA QMoE kernel already does sparse top-k decode; the only
gap was the export.

- _supported_qmoe_quantization: accept quant_method="olive" (blk32 int4
  integer-affine) alongside gptq/awq, and require a power-of-two block_size>=16
  (the CUDA QMoE constraint) so unrunnable configs fall back to dense.
- preprocess_olive_weights: preserve all leading dims when reshaping .qweight so
  fused expert-major tensors [E,N,packed_K] -> [E,N,n_blocks,blob_size] feed the
  QMoE repacker; 2-D linears are byte-identical (no behavior change).
- Qwen35MoECausalLMModel.preprocess_weights: guard the per-expert un-fuse behind
  use_qmoe; when QMoE is supported, keep fused expert-major weights and route
  through pack_qmoe_expert_weights (target_moe_path=".mlp") to emit one QMoE node
  per MoE layer. Mirrors DeepSeek-V3's use_qmoe path.
- deepseek: use the shared _supported_qmoe_quantization predicate so preprocess
  and MoELayer never disagree (DRY, avoids block_size drift).

Byte-equivalent to dense_fallback: same (q-zp)*scale affine, same softmax-top-k
routing, same SwiGLU (swiglu_fusion=2). Pure layout repack, no requantization.

Tests: Olive QMoE emission (fused node, zero per-expert MatMulNBits storm),
non-pow2 block_size -> dense fallback, and byte-exact fused-expert repack vs the
dense per-expert reference (dequant atol=0 + SwiGLU forward parity). Model-level
Qwen35 preprocess routing (QMoE params bind; dense path still un-fuses).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@justinchuby
justinchuby requested review from a team and Copilot August 3, 2026 17:30
@@ -0,0 +1,123 @@
# Copyright (c) Microsoft Corporation.
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

Performance Comparison

Comparing b82279f862912d

Model Metric Baseline Current Delta
bert (feature-extraction) model_size_bytes 359 KB 359 KB +0.0%
bert (feature-extraction) num_nodes 60 60 +0.0%
falcon model_size_bytes 364 KB 364 KB +0.0%
falcon num_nodes 68 68 +0.0%
gemma2 model_size_bytes 428 KB 428 KB +0.0%
gemma2 num_nodes 107 107 +0.0%
gpt2 model_size_bytes 388 KB 388 KB +0.0%
gpt2 num_nodes 54 54 +0.0%
llama model_size_bytes 425 KB 425 KB +0.0%
llama num_nodes 62 62 +0.0%
llama (static-cache) model_size_bytes 425 KB 425 KB +0.0%
llama (static-cache) num_nodes 58 58 +0.0%
mamba (ssm-text-generation) model_size_bytes 296 KB 296 KB +0.0%
mamba (ssm-text-generation) num_nodes 98 98 +0.0%
phi3 model_size_bytes 421 KB 421 KB +0.0%
phi3 num_nodes 60 60 +0.0%
phi3 (static-cache) model_size_bytes 421 KB 421 KB +0.0%
phi3 (static-cache) num_nodes 56 56 +0.0%
qwen2 model_size_bytes 425 KB 425 KB +0.0%
qwen2 num_nodes 62 62 +0.0%
qwen2 (static-cache) model_size_bytes 425 KB 425 KB +0.0%
qwen2 (static-cache) num_nodes 58 58 +0.0%
qwen3_5_moe (hybrid-text-generation) model_size_bytes 506 KB 506 KB +0.0%
qwen3_5_moe (hybrid-text-generation) num_nodes 275 275 +0.0%
qwen3_5_text (hybrid-text-generation) model_size_bytes 458 KB 458 KB +0.0%
qwen3_5_text (hybrid-text-generation) num_nodes 129 129 +0.0%
qwen3_5_vl (hybrid-qwen-vl) model_size_bytes 977 KB 977 KB +0.0%
qwen3_5_vl (hybrid-qwen-vl) num_nodes 413 413 +0.0%
t5 (seq2seq) model_size_bytes 836 KB 836 KB +0.0%
t5 (seq2seq) num_nodes 166 166 +0.0%
whisper (speech-to-text) model_size_bytes 1008 KB 1008 KB +0.0%
whisper (speech-to-text) num_nodes 128 128 +0.0%

No performance regressions.

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

🏗️ Architecture Diff

Comparing b82279f862912d

Model Sub-model Changes Status
bert (feature-extraction) model 0
falcon model 0
gemma2 model 0
gemma4 (gemma4) decoder 0
gemma4 (gemma4) embedding 0
gemma4 (gemma4) vision_encoder 0
gemma4_text model 0
gpt2 model 0
llama model 0
llama (static-cache) model 0
mamba (ssm-text-generation) model 0
phi3 model 0
phi3 (static-cache) model 0
qwen model 0
qwen (static-cache) model 0
qwen2 model 0
qwen2 (static-cache) model 0
qwen2_moe model 0
qwen2_moe (static-cache) model 0
qwen3 model 0
qwen3 (static-cache) model 0
qwen3_5_moe (hybrid-text-generation) model 0
qwen3_5_text (hybrid-text-generation) model 0
qwen3_5_vl (hybrid-qwen-vl) decoder 0
qwen3_5_vl (hybrid-qwen-vl) embedding 0
qwen3_5_vl (hybrid-qwen-vl) vision_encoder 0
qwen3_moe model 0
qwen3_moe (static-cache) model 0
qwen3_next (hybrid-text-generation) model 0
t5 (seq2seq) decoder 0
t5 (seq2seq) encoder 0
whisper (speech-to-text) decoder 0
whisper (speech-to-text) encoder 0

No architecture changes detected.


Legend: ⚪ No change · 🔵 Minor (attrs/inits) · 🟡 Moderate (nodes added/removed) · 🔴 Major (interface changed)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates MoE export for Qwen3.5/3.6-MoE (A3B) to emit a fused com.microsoft::QMoE node per MoE layer when the quantization config matches the CUDA QMoE ABI, enabling sparse top‑k expert execution for int4 decode instead of the dense per‑expert fallback graph.

Changes:

  • Expand the QMoE-eligibility predicate to include quant_method="olive" and enforce CUDA kernel constraints (power-of-two group_size >= 16) so unsupported configs fall back to the dense representation.
  • Update Olive weight preprocessing to preserve leading dimensions when reshaping .qweight, enabling expert-major fused tensors to be repacked into QMoE ABI layout.
  • Wire Qwen3.5-MoE and DeepSeek to use the shared QMoE predicate, and add targeted tests validating QMoE emission and expert-major packing.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/mobius/models/qwen35.py Gate MoE expert un-fusing behind use_qmoe and pack expert-major weights into QMoE parameters.
src/mobius/models/qwen35_test.py New build/weight tests focused on Qwen3.5-MoE QMoE emission and packed-weight binding.
src/mobius/models/deepseek.py Use shared _supported_qmoe_quantization predicate for consistent QMoE enablement.
src/mobius/components/_moe.py Extend QMoE support to Olive and add block-size constraints to avoid emitting unrunnable QMoE nodes.
src/mobius/components/_moe_test.py Add tests for Olive QMoE emission, fallback behavior, and byte-exact packing parity.
src/mobius/_weight_utils.py Preserve leading dims when reshaping Olive .qweight to support fused expert-major tensors.

Comment on lines +403 to +408
Accepts the integer-affine int4 block schemes whose ``(q - zero_point) *
scale`` dequantization is byte-identical to ``MatMulNBits`` and to the
``com.microsoft::QMoE`` kernel: GPTQ, AWQ, and Olive RTN
(``quant_method="olive"``, blk32 int4). The CUDA QMoE kernel requires a
power-of-two ``block_size >= 16``, so configs outside that range fall back
to the portable dense representation instead of emitting an unrunnable node.
Comment on lines +6 to +10
Focuses on the ``com.microsoft::QMoE`` emission path: when the quantization
config matches the native QMoE ABI (blk32 int4 Olive/GPTQ/AWQ),
:meth:`Qwen35MoECausalLMModel.preprocess_weights` keeps the fused expert-major
tensors and repacks them into ``fc1``/``fc2`` QMoE parameters (mirroring
DeepSeek-V3), instead of un-fusing into a per-expert dense fallback. All tiny
Comment on lines +380 to +384
# Unpack fused float expert weights into per-expert tensors for the
# dense fallback. When ``use_qmoe`` is set the fused quantized
# tensors arrive as ``.qweight``/``.scales``/``.qzeros`` (which do
# not match these suffixes) and are kept expert-major for
# ``pack_qmoe_expert_weights`` below.
Add fuse_dense_moe_to_qmoe, an ONNX-to-ONNX rewrite that recognises the
per-expert dense-fallback MoE subgraph (router MatMulNBits -> TopK -> Softmax,
per-expert Equal/Cast/Mul/ReduceSum masking, E x3 expert MatMulNBits MLPs and
the left-folded weighted sum) and rewrites it to a single com.microsoft::QMoE
node per MoE layer.

The existing int4 MatMulNBits expert weights and zero-points are reused
byte-for-byte (expert-major concat/flatten layout transform, no requant);
float16 scales are upcast to the float32 the QMoE kernel requires (lossless).
The router logits are Cast to float32 and passed as router_probs with
normalize_routing_weights=1, matching TopKGate. Any shared expert is preserved
and the final Add(routed, shared) is rewired onto the QMoE output.

Handles the merged-graph variant that inserts a Cast between the gate
MatMulNBits and TopK, and between TopK values and Softmax.

Tests build a tiny Qwen35-MoE-shaped dense graph and assert the E x3
MatMulNBits storm collapses to one QMoE node, the packed weights/zero-points
are bit-identical to the concatenated per-expert tensors, scales are a lossless
float32 upcast, and a reference forward matches the dense forward.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 3, 2026 19:20

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

src/mobius/rewrite_rules/_qmoe_fusion.py:220

  • Dense-fallback graphs may wire the constant expert id on either side of Equal (Equal is commutative). _collect_experts currently assumes the constant is always equal.inputs[1], so it will silently miss experts (and skip fusing) when the constant is equal.inputs[0] and indices are equal.inputs[1].
        indices = self.topk.outputs[1]
        for equal in _consumers_of_type(indices, "Equal"):
            expert_id = _scalar_int(equal.inputs[1])
            if expert_id is None:
                continue

Comment on lines +314 to +315
bits, block_size, has_zp = _expert_geometry(down_nodes[0])

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.

3 participants