You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
0 commit comments