Skip to content

perf: run block-wise int4/int8 QMoE CPU experts on the MLAS QNBit GEMM kernels - #32644

Open
Yuri Khrustalev (ykhrustalev) wants to merge 5 commits into
microsoft:mainfrom
ykhrustalev:ykhrustalev/qmoe-cpu-qnbit-gemm
Open

Yuri Khrustalev (ykhrustalev) wants to merge 5 commits into
microsoft:mainfrom
ykhrustalev:ykhrustalev/qmoe-cpu-qnbit-gemm

Conversation

@ykhrustalev

@ykhrustalev Yuri Khrustalev (ykhrustalev) commented Sep 16, 2026

Copy link
Copy Markdown

Description

Problem
The CPU QMoE kernel dequantizes block-wise int4/int8 experts to fp32 and runs SGEMM, threading one expert per core. For decode only top_k experts are active, so most of the pool sits idle (3 tok/s on an 8B-A1B model). The existing MLAS Q4 fast path is AVX-512 only, off by default, and re-quantizes the weights to a different grid.

Solution

  • Pre-packs block-wise 4/8-bit experts with constant scales (and constant zero points, if present) for the MLAS QNBit GEMM kernels (the MatMulNBits kernels) at load time, one MlasQNBitGemmPackQuantBData per expert, with no re-quantization and no unpacked fp32/uint8 copies. QMoE's per-expert zero point layout is the MatMulNBits one, so asymmetric checkpoints take the same kernels
  • Runs each expert GEMM with MlasQNBitGemmBatch; when fewer experts are active than there are threads, experts run sequentially and each GEMM gets the whole pool
  • Adds an accuracy_level attribute to QMoE with the MatMulNBits meaning: the default keeps fp32 activations where MLAS has that kernel (4-bit; 8-bit has no fp32 variant and keeps the dequantize path), 4 allows int8 activations. ORT_QMOE_CPU_QNBIT_GEMM (fp32, int8, 0) overrides the attribute
  • Supports shared pre-packed weights across sessions via a tagged layout in the prepacked shape buffer that records the compute type and zero point flag, so a buffer packed for other kernels is rejected instead of used
  • Logs once, with the reason, when a block-wise node cannot use the path, since the fallback is an order of magnitude slower for decode
  • Row-wise scales, 2-bit, and the other EPs are unchanged

Testing

  • New MoETest.QMoETest_CPU_Int4_BlockWise_* / QMoETest_CPU_Int8_BlockWise_* cases with an fp32 reference: decode, prefill, bias + block 64, fp16, accuracy_level=4, zero points (4-bit fp32 and int8, 8-bit int8), and two-session shared pre-packed weights. All MoETest.* and MatMulNBits* cases pass in onnxruntime_provider_test on Linux x64 (AVX-512).

  • LFM2.5-8B-A1B (32 experts, top-4, int4 block 32, signed-scale export from feat: add LFM2-MoE support onnxruntime-genai#2575), Xeon 8358 x30, same build for all rows (0 = previous dequantize path). Decode is genai greedy generation; prefill is a single InferenceSession.run; KL is against HF fp32 logits on an 81-token chat-template prompt:

    ORT_QMOE_CPU_QNBIT_GEMM decode tok/s prefill 256 tok/s prefill 16 tok/s KL vs HF fp32 top-1 agree
    0 (before) 3.2 147 27 0.096 0.901
    unset (fp32, the default) 55.2 346 148 0.098 0.889
    int8 (accuracy_level=4) 60.7 364 103 0.106 0.889

    Earlier int8-only measurements on an M3 Ultra: decode 4 → 125 tok/s, prefill 256 tokens 190 → 855 tok/s, KL 0.112 → 0.101. KleidiAI only serves the int8 compute type, so the fp32 default on Apple Silicon uses the NEON fp32 kernels and has not been measured yet.

Motivation and Context

Makes block-wise quantized MoE models usable for CPU decode by reusing the MatMulNBits kernel set instead of dequantizing per call. fp32 activations are the default because int8 is measurably lossier; accuracy_level=4 opts in per model, matching MatMulNBits. Pairs with microsoft/onnxruntime-genai#2575, which exports LFM2-MoE models with this encoding.

…M kernels

The CPU QMoE kernel dequantized block-wise experts to fp32 and ran SGEMM,
one expert per thread, so decode (top_k active experts) left most of the
pool idle. Experts are now pre-packed once for the MatMulNBits kernels (no
re-quantization) and each expert GEMM runs on them, sequentially with the
whole pool when fewer experts are active than threads.
ORT_QMOE_CPU_QNBIT_GEMM selects fp32 (default) or int8 activations, or
disables the path.
Copilot AI balanced review requested due to automatic review settings September 16, 2026 15:09
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

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.

🟡 Changes recommended

The 8-bit default contradicts the documented behavior, and packed-buffer sizing can overflow.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds an MLAS QNBit GEMM fast path for block-wise quantized CPU QMoE experts.

Changes:

  • Pre-packs int4/int8 expert weights.
  • Adds QNBit execution, threading, and sharing support.
  • Adds CPU correctness and sharing tests.
File summaries
File Description
moe_quantization_cpu.h Declares QNBit packed state and helpers.
moe_quantization_cpu.cc Implements packing, execution, policy, and sharing.
moe_test.cc Adds block-wise QMoE tests.
Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 3
  • Review effort level: Balanced

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

const size_t nbits = static_cast<size_t>(expert_weight_bits_);
const size_t blk = static_cast<size_t>(block_size_);
const size_t per_expert = packed.packed_size_per_expert;
const size_t total_packed_size = per_expert * static_cast<size_t>(num_experts);
Comment on lines +966 to +970
} else if (allow_int8_compute && MlasIsQNBitGemmAvailable(nbits, blk, SQNBIT_CompInt8)) {
qnbit_compute_type_ = SQNBIT_CompInt8;
} else {
use_qnbit_gemm_ = false;
}
// Experts pack independently into disjoint regions, so spread them over a load-time pool
// (the session pool is not reachable from PrePack; same approach as MatMulNBits::PrePack).
OrtThreadPoolParams pack_tp_params;
pack_tp_params.thread_pool_size = Env::Default().GetNumPhysicalCpuCores();
Make int8 activations opt-in (ORT_QMOE_CPU_QNBIT_GEMM=int8), matching
MatMulNBits without accuracy_level=4; 8-bit experts keep the dequantize
path by default. Record the compute type in the tagged prepacked shape
buffer and reject shared buffers packed for another compute type. Share
one shape-buffer writer across the three prepack layouts, return the
packed size from the eligibility check instead of recomputing it, pass
fp32 scales to the GEMM lambda by expert index, and only spin up the
load-time pack pool when there is more than one expert. Split the 8-bit
test into default (dequantize) and int8-activation cases.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ng on the CPU QNBit path

Add an accuracy_level attribute to QMoE with the MatMulNBits meaning so a
model can opt into int8 activations (4) instead of relying on the
ORT_QMOE_CPU_QNBIT_GEMM environment variable, which now only overrides
the attribute. Pack constant block-wise zero points into the QNBit
layout (QMoE's per-expert zero point layout is the MatMulNBits one) so
asymmetric checkpoints take the same kernels. Log once when a block-wise
node cannot use the path and why, since the dequantize fallback is an
order of magnitude slower for decode. Record the zero point flag next to
the compute type in the prepacked shape buffer, and cover accuracy_level
and zero points (4-bit fp32 and int8, 8-bit int8) in the tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…Bit prepack

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@mirounga
mirounga self-requested a review September 16, 2026 16:58
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.

2 participants