Skip to content

Commit 781bbe8

Browse files
authored
Fix QMoE scale precision: scales silently forced entire MoE FFN onto CPU EP (#505)
## Bug When exporting a hybrid MoE model (e.g. `Qwen/Qwen3.6-35B-A3B`) via the native QMoE-emission path in `MoELayer` (`_moe.py`) at a non-float32 precision (e.g. `fp16` for CUDA), the `fc1_scales`/`fc2_scales` parameters were pinned with `_keep_float32 = True`, which `_cast_module_dtype()` (`_builder.py`) honors by skipping them during the FLOAT->target-dtype cast pass. Every other parameter (weights, activations) was correctly cast to FLOAT16, but the scales stayed FLOAT32. ONNX Runtime's `com.microsoft::QMoE` kernel (`quant_type="int"`) requires its `T2` type constraint (used by `fc1_scales`/`fc2_scales`) to be **exactly equal to `T`** (the activation dtype) — confirmed by reading `onnxruntime/contrib_ops/{cuda,cpu}/moe/moe_quantization*.{cc,h}`. There is no registered kernel variant for `T2=FLOAT32` while `T=FLOAT16`. Because of this mismatch, ONNX Runtime silently fails to find *any* matching QMoE kernel — on **either** CPU or CUDA EP — and falls back to placing every QMoE node on `CPUExecutionProvider`, inserting `InsertedPrecisionFreeCast_*` bridging nodes. Since QMoE is the entire MoE FFN compute (the dominant cost for a large MoE model), this silently forced 100% of the MoE compute onto CPU even when the workflow explicitly targeted CUDA — and also broke `enable_cuda_graph` in ONNX Runtime GenAI, since not all decoder nodes were assigned to the same EP. ## Fix - `_moe.py`: stop pinning `fc1_scales`/`fc2_scales` to FLOAT32; let them be downcast to the model's target export precision (FP16/BF16) like every other float parameter. - `_qmoe_fusion.py` (the separate "dense-fallback -> QMoE" rewrite rule): had the same class of bug — scales and router logits were hardcoded to FLOAT/FLOAT32 regardless of the model's activation dtype. Fixed to use the activation dtype instead. - Updated `_moe_test.py` and `_qmoe_fusion_test.py` to reflect the corrected (dtype-matching) behavior, and added explicit FP32/FP16/BF16 coverage in `_qmoe_fusion_test.py`. ## Validation - `python -m pytest src/mobius/components/_moe_test.py src/mobius/rewrite_rules/_qmoe_fusion_test.py -v` — 39 passed. - `ruff` — passed. - Real-model validation: re-ran the Olive `Rtn(moe=true)` + `MobiusBuilder(precision=fp16, CUDA EP)` export against `Qwen/Qwen3.6-35B-A3B`. All 430 QMoE scale initializers in the exported graph are now FLOAT16 (previously FLOAT32). - Loaded the re-exported model with `onnxruntime` (verbose session logging): **zero** "CUDA kernel not found in registries" messages for QMoE, and all 40 QMoE nodes are now correctly assigned to `CUDAExecutionProvider` (previously all 40 fell back to CPU). - `onnxruntime_genai.Model(...)` now loads successfully with `enable_cuda_graph=1` in `genai_config.json`, with no manual workaround needed (previously required manually stripping `enable_cuda_graph` from the generated config). Related: builds on top of the export fixes merged in #495.
1 parent 9d540af commit 781bbe8

5 files changed

Lines changed: 119 additions & 42 deletions

File tree

src/mobius/components/_moe.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -371,15 +371,13 @@ def _init_qmoe_parameters(self, expert_config: ArchitectureConfig) -> None:
371371
dtype=ir.DataType.UINT8,
372372
)
373373
self.fc1_scales = nn.Parameter([self.num_experts, fc1_out, hidden_size // block_size])
374-
self.fc1_scales._keep_float32 = True
375374
self.fc2_experts_weights = nn.Parameter(
376375
[self.num_experts, hidden_size, intermediate_size * bits // 8],
377376
dtype=ir.DataType.UINT8,
378377
)
379378
self.fc2_scales = nn.Parameter(
380379
[self.num_experts, hidden_size, intermediate_size // block_size]
381380
)
382-
self.fc2_scales._keep_float32 = True
383381
if quantization.sym:
384382
self.fc1_experts_zero_points = None
385383
self.fc2_experts_zero_points = None

src/mobius/components/_moe_test.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -244,8 +244,8 @@ def test_int4_moe_emits_expert_major_qmoe(self):
244244

245245
_cast_module_dtype(layer, ir.DataType.FLOAT16)
246246
assert layer.gate.weight.dtype == ir.DataType.FLOAT16
247-
assert layer.fc1_scales.dtype == ir.DataType.FLOAT
248-
assert layer.fc2_scales.dtype == ir.DataType.FLOAT
247+
assert layer.fc1_scales.dtype == ir.DataType.FLOAT16
248+
assert layer.fc2_scales.dtype == ir.DataType.FLOAT16
249249

250250
def test_expert_major_packing_matches_static_64_expert_top6_reference(self):
251251
"""Packed QMoE math matches the existing loop-over-experts semantics."""

src/mobius/rewrite_rules/_group_query_attention_test.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -327,12 +327,14 @@ def test_deepseek_v2_lite_int4_uses_one_qmoe_per_moe_layer(self):
327327
# computed in FLOAT32 for routing-score numerical stability.
328328
assert node.inputs[1] is not None
329329
assert node.inputs[1].dtype == ir.DataType.FLOAT16
330-
# fc1_scales/fc2_scales (indices 3, 6) are quantization scale
331-
# tensors, independent of the hidden_states/router dtype.
330+
# fc1_scales/fc2_scales (indices 3, 6) must match the
331+
# hidden_states/activation dtype (FLOAT16 here): QMoE's kernel
332+
# registration requires T2 (scales) to exactly equal T
333+
# (activation), so scales are no longer pinned to FLOAT32.
332334
assert node.inputs[3] is not None
333-
assert node.inputs[3].dtype == ir.DataType.FLOAT
335+
assert node.inputs[3].dtype == ir.DataType.FLOAT16
334336
assert node.inputs[6] is not None
335-
assert node.inputs[6].dtype == ir.DataType.FLOAT
337+
assert node.inputs[6].dtype == ir.DataType.FLOAT16
336338
assert node.inputs[14] is not None
337339
assert node.inputs[14].dtype == ir.DataType.FLOAT16
338340

src/mobius/rewrite_rules/_qmoe_fusion.py

Lines changed: 43 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,15 @@
2121
(the packed ``uint8`` bytes are copied unchanged; ``swiglu_fusion=2`` tells the
2222
kernel the gate half precedes the up half).
2323
* ``fc2_experts_weights`` = per-expert ``flatten(down_w)`` (bytes unchanged).
24-
* scales are upcast ``float16 -> float32`` (value-exact, lossless) because the
25-
QMoE kernel requires ``float32`` scales and ``router_probs``.
24+
* scales are cast to the activation dtype because QMoE's ``T2`` kernel type
25+
constraint requires integer-quantization scales to match ``input``.
2626
* zero-points are copied unchanged (same low-nibble-first packing as
2727
``MatMulNBits``).
2828
29-
The router logits (a quantized ``MatMulNBits`` gate) are ``Cast`` to ``float32``
30-
and passed as ``router_probs`` with ``normalize_routing_weights=1`` -- matching
31-
:class:`mobius.components._moe.TopKGate` (``Softmax(TopK(logits))``).
29+
The router logits (a quantized ``MatMulNBits`` gate) are cast to the activation
30+
dtype and passed as ``router_probs`` with ``normalize_routing_weights=1`` --
31+
matching :class:`mobius.components._moe.TopKGate`
32+
(``Softmax(TopK(logits))``).
3233
3334
Any surrounding **shared expert** (Qwen2-MoE style ``shared_expert`` +
3435
``shared_expert_gate``) is left untouched: only the routed per-expert storm and
@@ -319,12 +320,14 @@ def _pack_projection(nodes: list[ir.Node], slot: int) -> np.ndarray:
319320
return np.stack(stacked, axis=0)
320321

321322

322-
def _pack_scales(nodes: list[ir.Node], slot: int, out_features: int) -> np.ndarray:
323+
def _pack_scales(
324+
nodes: list[ir.Node], slot: int, out_features: int, dtype: ir.DataType
325+
) -> np.ndarray:
323326
stacked = []
324327
for node in nodes:
325328
arr = _array(node.inputs[slot]).reshape(out_features, -1)
326-
stacked.append(arr.astype(np.float32))
327-
return np.stack(stacked, axis=0)
329+
stacked.append(arr)
330+
return np.stack(stacked, axis=0).astype(dtype.numpy())
328331

329332

330333
def _pack_zero_points(nodes: list[ir.Node], slot: int, out_features: int) -> np.ndarray:
@@ -334,7 +337,9 @@ def _pack_zero_points(nodes: list[ir.Node], slot: int, out_features: int) -> np.
334337
return np.stack(stacked, axis=0)
335338

336339

337-
def _fuse_layer(graph: ir.Graph, layer: _DenseMoELayer, index: int) -> None:
340+
def _fuse_layer(
341+
graph: ir.Graph, layer: _DenseMoELayer, index: int, activation_dtype: ir.DataType
342+
) -> None:
338343
ids = sorted(layer.experts)
339344
gate_nodes = [layer.experts[i].gate for i in ids]
340345
up_nodes = [layer.experts[i].up for i in ids]
@@ -350,10 +355,10 @@ def _fuse_layer(graph: ir.Graph, layer: _DenseMoELayer, index: int) -> None:
350355
fc1_w = np.concatenate([gate_w, up_w], axis=1)
351356
fc2_w = _pack_projection(down_nodes, 1)
352357

353-
gate_s = _pack_scales(gate_nodes, 2, inter)
354-
up_s = _pack_scales(up_nodes, 2, inter)
358+
gate_s = _pack_scales(gate_nodes, 2, inter, activation_dtype)
359+
up_s = _pack_scales(up_nodes, 2, inter, activation_dtype)
355360
fc1_s = np.concatenate([gate_s, up_s], axis=1)
356-
fc2_s = _pack_scales(down_nodes, 2, hidden_dim)
361+
fc2_s = _pack_scales(down_nodes, 2, hidden_dim, activation_dtype)
357362

358363
fc1_zp = fc2_zp = None
359364
if has_zp:
@@ -366,11 +371,11 @@ def _fuse_layer(graph: ir.Graph, layer: _DenseMoELayer, index: int) -> None:
366371
fc1_w_v = _make_initializer(
367372
graph, f"{prefix}.fc1_experts_weights", fc1_w, ir.DataType.UINT8
368373
)
369-
fc1_s_v = _make_initializer(graph, f"{prefix}.fc1_scales", fc1_s, ir.DataType.FLOAT)
374+
fc1_s_v = _make_initializer(graph, f"{prefix}.fc1_scales", fc1_s, activation_dtype)
370375
fc2_w_v = _make_initializer(
371376
graph, f"{prefix}.fc2_experts_weights", fc2_w, ir.DataType.UINT8
372377
)
373-
fc2_s_v = _make_initializer(graph, f"{prefix}.fc2_scales", fc2_s, ir.DataType.FLOAT)
378+
fc2_s_v = _make_initializer(graph, f"{prefix}.fc2_scales", fc2_s, activation_dtype)
374379
fc1_zp_v = (
375380
_make_initializer(graph, f"{prefix}.fc1_zero_points", fc1_zp, ir.DataType.UINT8)
376381
if fc1_zp is not None
@@ -385,7 +390,7 @@ def _fuse_layer(graph: ir.Graph, layer: _DenseMoELayer, index: int) -> None:
385390
cast = ir.node(
386391
"Cast",
387392
inputs=[layer.logits],
388-
attributes={"to": ir.DataType.FLOAT.value},
393+
attributes={"to": activation_dtype.value},
389394
num_outputs=1,
390395
name=f"{prefix}.router_probs_cast",
391396
)
@@ -460,9 +465,9 @@ def fuse_dense_moe_to_qmoe(model: ir.Model) -> int:
460465
"""Fuse every dense-fallback MoE subgraph in ``model`` into ``QMoE`` nodes.
461466
462467
Reuses the existing int4 ``MatMulNBits`` expert weights byte-for-byte (pure
463-
expert-major concat/layout transform, no requantization). ``float16`` scales
464-
are upcast to the ``float32`` the QMoE kernel requires (lossless). Any shared
465-
expert is preserved.
468+
expert-major concat/layout transform, no requantization). Scales and router
469+
probabilities are cast to the activation dtype required by QMoE's kernel
470+
type constraints. Any shared expert is preserved.
466471
467472
Args:
468473
model: The IR model to rewrite in place.
@@ -492,7 +497,26 @@ def fuse_dense_moe_to_qmoe(model: ir.Model) -> int:
492497
block_size,
493498
)
494499
continue
495-
_fuse_layer(graph, layer, index)
500+
activation_dtype = layer.hidden.dtype
501+
if activation_dtype is None:
502+
# `layer.hidden` is often a graph-internal, not-yet-type-inferred
503+
# value in real models. Fall back to the dtype of an existing
504+
# expert's MatMulNBits scales, which already matches the
505+
# pre-fusion activation dtype (scales share QMoE's/MatMulNBits's
506+
# "T" type constraint with the activation).
507+
activation_dtype = down.inputs[2].dtype
508+
if activation_dtype not in {
509+
ir.DataType.FLOAT,
510+
ir.DataType.FLOAT16,
511+
ir.DataType.BFLOAT16,
512+
}:
513+
logger.warning(
514+
"skipping MoE layer at %s: QMoE activation dtype is missing or unsupported (%s)",
515+
layer.topk.name,
516+
activation_dtype,
517+
)
518+
continue
519+
_fuse_layer(graph, layer, index, activation_dtype)
496520
fused += 1
497521
if fused:
498522
_remove_dead_nodes(graph)

src/mobius/rewrite_rules/_qmoe_fusion_test.py

Lines changed: 68 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -92,15 +92,24 @@ def _dequant(
9292
class _Quant:
9393
"""A randomly-generated int4 ``MatMulNBits`` weight triple."""
9494

95-
def __init__(self, rng: np.random.Generator, n: int, kdim: int) -> None:
95+
def __init__(
96+
self,
97+
rng: np.random.Generator,
98+
n: int,
99+
kdim: int,
100+
scale_dtype: ir.DataType = ir.DataType.FLOAT16,
101+
) -> None:
96102
n_blocks = kdim // BLOCK
97103
codes = rng.integers(0, 16, size=(n, kdim), dtype=np.int32)
98104
zp_codes = rng.integers(0, 16, size=(n, n_blocks), dtype=np.int32)
99105
self.weight = _pack_weight(codes, BLOCK)
100-
self.scales = rng.random((n, n_blocks), dtype=np.float32).astype(np.float16) * 0.1
106+
self.scales = (rng.random((n, n_blocks), dtype=np.float32) * 0.1).astype(
107+
scale_dtype.numpy()
108+
)
101109
self.zero_points = _pack_zero_points(zp_codes)
102110
self.n = n
103111
self.kdim = kdim
112+
self.scale_dtype = scale_dtype
104113

105114
@property
106115
def dense(self) -> np.ndarray:
@@ -118,7 +127,7 @@ def _init(graph: ir.Graph, name: str, arr: np.ndarray, dtype: ir.DataType) -> ir
118127

119128
def _matmulnbits(name: str, x: ir.Value, q: _Quant, graph: ir.Graph) -> ir.Value:
120129
w = _init(graph, f"{name}.weight", q.weight, ir.DataType.UINT8)
121-
s = _init(graph, f"{name}.scales", q.scales, ir.DataType.FLOAT16)
130+
s = _init(graph, f"{name}.scales", q.scales, q.scale_dtype)
122131
z = _init(graph, f"{name}.zero_points", q.zero_points, ir.DataType.UINT8)
123132
node = ir.node(
124133
"MatMulNBits",
@@ -145,21 +154,33 @@ def _constant_int(graph_nodes: list[ir.Node], name: str, value: int) -> ir.Value
145154
return node.outputs[0]
146155

147156

148-
def _build_dense_graph() -> tuple[ir.Model, dict[str, _Quant], np.ndarray]:
149-
"""Build a tiny dense-fallback Qwen35-MoE graph and return it with its weights."""
157+
def _build_dense_graph(
158+
activation_dtype: ir.DataType = ir.DataType.FLOAT16,
159+
*,
160+
hidden_dtype: ir.DataType | None = None,
161+
) -> tuple[ir.Model, dict[str, _Quant], np.ndarray]:
162+
"""Build a tiny dense-fallback Qwen35-MoE graph and return it with its weights.
163+
164+
``activation_dtype`` controls the dtype of MatMulNBits scales/router casts.
165+
``hidden_dtype`` (defaults to ``activation_dtype``) controls the declared
166+
type of the ``hidden`` graph input; pass ``ir.DataType.UNDEFINED`` to
167+
simulate a not-yet-type-inferred graph-internal value.
168+
"""
150169
rng = _rng()
151170
quants: dict[str, _Quant] = {}
152171
nodes: list[ir.Node] = []
172+
if hidden_dtype is None:
173+
hidden_dtype = activation_dtype
153174

154175
hidden = ir.Value(
155176
name="hidden",
156177
shape=ir.Shape(["T", H]),
157-
type=ir.TensorType(ir.DataType.FLOAT16),
178+
type=ir.TensorType(hidden_dtype) if hidden_dtype != ir.DataType.UNDEFINED else None,
158179
)
159180
graph = ir.Graph([hidden], [], nodes=[], name="tiny_moe")
160181

161182
def q(key: str, n: int, kdim: int) -> _Quant:
162-
quants[key] = _Quant(rng, n, kdim)
183+
quants[key] = _Quant(rng, n, kdim, scale_dtype=activation_dtype)
163184
return quants[key]
164185

165186
# Router: quantized gate MatMulNBits -> TopK -> Softmax.
@@ -287,7 +308,7 @@ def q(key: str, n: int, kdim: int) -> _Quant:
287308
)
288309
final.outputs[0].name = "moe_out"
289310
final.outputs[0].shape = ir.Shape(["T", H])
290-
final.outputs[0].type = ir.TensorType(ir.DataType.FLOAT16)
311+
final.outputs[0].type = ir.TensorType(activation_dtype)
291312
nodes.append(final)
292313

293314
for node in nodes:
@@ -425,22 +446,50 @@ def test_qmoe_weights_are_bit_identical_to_concatenated_experts() -> None:
425446
np.testing.assert_array_equal(fc2_z[e], quants[f"d{e}"].zero_points)
426447

427448

428-
def test_scales_are_lossless_float32_upcast() -> None:
429-
model, quants, _ = _build_dense_graph()
449+
@pytest.mark.parametrize(
450+
"activation_dtype",
451+
[ir.DataType.FLOAT, ir.DataType.FLOAT16, ir.DataType.BFLOAT16],
452+
)
453+
def test_scales_match_activation_dtype(activation_dtype: ir.DataType) -> None:
454+
model, quants, _ = _build_dense_graph(activation_dtype)
430455
fuse_dense_moe_to_qmoe(model)
431456
qmoe = next(n for n in model.graph if n.op_type == "QMoE")
432457

433458
fc1_s = qmoe.inputs[3].const_value.numpy()
434-
assert fc1_s.dtype == np.float32
459+
fc2_s = qmoe.inputs[6].const_value.numpy()
460+
assert qmoe.inputs[3].dtype == activation_dtype
461+
assert qmoe.inputs[6].dtype == activation_dtype
462+
assert fc1_s.dtype == activation_dtype.numpy()
463+
assert fc2_s.dtype == activation_dtype.numpy()
435464
for e in range(E):
436465
expected = np.concatenate(
437466
[
438467
quants[f"g{e}"].scales.reshape(INTER, -1),
439468
quants[f"u{e}"].scales.reshape(INTER, -1),
440469
],
441470
axis=0,
442-
).astype(np.float32)
471+
).astype(activation_dtype.numpy())
443472
np.testing.assert_array_equal(fc1_s[e], expected)
473+
expected_fc2 = quants[f"d{e}"].scales.reshape(H, -1).astype(activation_dtype.numpy())
474+
np.testing.assert_array_equal(fc2_s[e], expected_fc2)
475+
476+
477+
def test_fuses_when_hidden_dtype_is_untyped() -> None:
478+
"""Graph-internal `hidden` values are commonly untyped before shape inference.
479+
480+
The fusion must still succeed by falling back to the dtype of an existing
481+
expert's MatMulNBits scales, rather than silently skipping the layer.
482+
"""
483+
model, _, _ = _build_dense_graph(ir.DataType.FLOAT16, hidden_dtype=ir.DataType.UNDEFINED)
484+
graph = model.graph
485+
assert graph.inputs[0].dtype is None
486+
487+
fused = fuse_dense_moe_to_qmoe(model)
488+
489+
assert fused == 1
490+
qmoe = next(n for n in graph if n.op_type == "QMoE")
491+
assert qmoe.inputs[3].dtype == ir.DataType.FLOAT16
492+
assert qmoe.inputs[6].dtype == ir.DataType.FLOAT16
444493

445494

446495
def test_rewritten_forward_matches_dense_forward() -> None:
@@ -451,15 +500,19 @@ def test_rewritten_forward_matches_dense_forward() -> None:
451500
np.testing.assert_allclose(got, expected, rtol=1e-5, atol=1e-5)
452501

453502

454-
def test_router_probs_cast_to_float32() -> None:
455-
model, _, _ = _build_dense_graph()
503+
@pytest.mark.parametrize(
504+
"activation_dtype",
505+
[ir.DataType.FLOAT, ir.DataType.FLOAT16, ir.DataType.BFLOAT16],
506+
)
507+
def test_router_probs_cast_to_activation_dtype(activation_dtype: ir.DataType) -> None:
508+
model, _, _ = _build_dense_graph(activation_dtype)
456509
fuse_dense_moe_to_qmoe(model)
457510
graph = model.graph
458511
qmoe = next(n for n in graph if n.op_type == "QMoE")
459512
router_probs = qmoe.inputs[1]
460513
cast = router_probs.producer()
461514
assert cast.op_type == "Cast"
462-
assert cast.attributes["to"].value == ir.DataType.FLOAT.value
515+
assert cast.attributes["to"].value == activation_dtype.value
463516

464517

465518
def test_attributes_match_qmoe_abi() -> None:

0 commit comments

Comments
 (0)