docs/contrib_ops/cpu/qmoe.md
This document describes the current CPU implementation of the com.microsoft.QMoE operator in ONNX Runtime.
The CPU QMoE kernel is implemented in:
onnxruntime/contrib_ops/cpu/moe/moe_quantization_cpu.honnxruntime/contrib_ops/cpu/moe/moe_quantization_cpu.cconnxruntime/contrib_ops/cpu/moe/moe_helper.hThe operator schema itself is defined in:
onnxruntime/core/graph/contrib_ops/contrib_defs.ccThis document focuses on runtime behavior on the CPU Execution Provider, not on the general QMoE schema.
At a high level, the CPU kernel executes QMoE in five stages:
router_probs or router_weights.The implementation keeps routing and accumulation shared across bit-widths. The main bit-width-specific differences are in how expert weights are prepared and how the FC1/FC2 GEMMs are executed.
T): float or MLFloat16T2): float or MLFloat16T1): uint8For CPU, the kernel currently accepts:
expert_weight_bits = 2expert_weight_bits = 4expert_weight_bits = 8Internally, most CPU compute is performed in float. MLFloat16 inputs such as activations, router values, scales, and biases are converted to float scratch buffers when needed.
The CPU implementation supports both row-wise and block-wise quantization.
block_size = 0Weight tensor shapes:
(num_experts, fc1_out_features, hidden_size / pack_size)(num_experts, hidden_size, inter_size / pack_size)block_size > 0Weight tensor shapes (same as row-wise — block-wise only changes scales):
(num_experts, fc1_out_features, hidden_size / pack_size)(num_experts, hidden_size, inter_size / pack_size)Scale tensor shapes (ceiling division for partial last blocks):
(num_experts, fc1_out_features, ceil(hidden_size / block_size))(num_experts, hidden_size, ceil(inter_size / block_size))For packed weights:
pack_size = 8 / expert_weight_bitsThe CPU implementation validates that packed dimensions are compatible with pack_size, and also validates that hidden_size and inferred inter_size divide cleanly where required.
Routing is shared for all bit-widths.
For each input token:
router_probs.k experts.router_probs, orrouter_weights if that optional input is provided(token, expert, weight) assignments into per-expert route lists.To reduce contention, the routing stage first builds thread-local expert-token maps and merges them afterward.
After routing, the kernel processes experts independently:
A1The final output tensor is produced by reducing all thread-local output buffers.
The CPU implementation uses multiple execution paths depending on bit-width, quantization style, and MLAS support.
The 2-bit path has a dedicated fast path for block-wise quantization using MLAS LUT GEMM.
This is used when:
expert_weight_bits == 2As of the current MLAS implementation, this effectively requires:
block_sizeblock_size compatible with LUT GEMMThe CPU kernel:
MlasLutGemm for FC1 and/or FC2.The LUT-specific helper logic is intentionally isolated from the shared routing and accumulation flow.
If LUT GEMM is not available for a particular shape or layout, the kernel falls back to:
floatMlasGemmThis fallback supports both row-wise and block-wise quantization.
The 4-bit path supports several modes:
When the configuration is compatible, the kernel can use MLAS Q4 GEMM directly.
This path is used only for symmetric 4-bit weights where the MLAS Q4 layout is supported.
If direct Q4 GEMM is not used, the kernel can reuse prepacked transposed/unpacked buffers and then run:
MlasGemmOtherwise, the kernel dequantizes directly from the packed ONNX input tensors and runs MlasGemm.
The 8-bit path currently uses the general dequantize-plus-GEMM flow. It shares the same routing, activation, and output accumulation logic as the other paths.
The CPU kernel implements PrePack() and UseSharedPrePackedBuffers() to reduce repeated setup cost.
When FC1/FC2 weights and scales, plus any zero points, are constant initializers and the block-wise shape is
supported by MLAS LUT GEMM, the kernel pre-packs the weights into MLAS LUT GEMM packed buffers. These packed
buffers are cached and can be reused across sessions through shared prepacked buffers. If scales or zero points are
runtime inputs, execution falls back to packing per expert during Compute().
The kernel pre-packs 4-bit weights into a transposed/unpacked format used by the fallback path. When possible, it also creates a direct MLAS Q4 packed cache for faster execution.
There is no special MLAS packed path today analogous to the 2-bit LUT or 4-bit Q4 paths.
The CPU QMoE kernel currently supports:
identitygelurelusiluswigluFor CPU, SwiGLU requires swiglu_fusion = 1, meaning FC1 output is interpreted as interleaved gate/value data and activation writes directly into the FC2 input buffer.
Biases are optional for FC1 and FC2.
float and applies it afterward.Optional zero-point tensors are supported for FC1 and FC2.
The direct 4-bit MLAS Q4 path only supports symmetric weights, so it is not used when zero points are present.
The 2-bit LUT path supports both symmetric and asymmetric packed inputs as long as the MLAS LUT requirements are satisfied.
The CPU implementation uses the ORT thread pool in several phases:
It avoids global write contention by accumulating expert outputs into thread-local output buffers and reducing them at the end.
The current CPU QMoE implementation has a few important limitations:
float scratch buffers before GEMM, which is simpler but slower than a fully native low-bit kernelmoe_helper::CheckInputs(...)
QMoECPU<T>::PrePack(...)
QMoECPU<T>::UseSharedPrePackedBuffers(...)
QMoECPU<T>::Compute(...)
TryRunLutGemm(...)
Relevant CPU-side tests live in:
onnxruntime/test/contrib_ops/moe_test.cconnxruntime/test/python/transformers/test_qmoe_cpu.pyThese cover:
Likely future improvements include: