Repository navigation
perf: run block-wise int4/int8 QMoE CPU experts on the MLAS QNBit GEMM kernels - #32644
Tianlei Wu (tianleiwu) merged 11 commits into
Conversation
…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.
|
Azure Pipelines: There may be pipelines that require an authorized user to comment /azp run to run. |
There was a problem hiding this comment.
🟡 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.
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>
Use int8 activations where MLAS has no fp32 kernel for the bit width (8-bit), so 8-bit experts reach the QNBit path by default as documented; explicit fp32 still forces the previous behavior. Guard the packed-buffer size with SafeInt and size the load-time pack pool by the expert count.
8-bit experts now run with int8 activations by default, so the block-wise 8-bit case carries their quantization error; match the tolerance of the other int8 cases and add a case that forces fp32 back onto the dequantize path.
|
Thanks — addressed all three.
All 17 |
Tianlei Wu (tianleiwu)
left a comment
There was a problem hiding this comment.
Reviewed at d026ecb0. The QNBit packing, compute, zero-point, and shared-buffer plumbing follows the established MatMulNBits contracts. The earlier packed-size overflow, int8-default routing, and pack-thread-count concerns are fixed at this head. I left two non-blocking inline suggestions for route coverage and comment accuracy.
…check, assert pre-packing in 8-bit QNBit tests Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Tianlei Wu (tianleiwu)
left a comment
There was a problem hiding this comment.
Reviewed at 61173ff78c76a4d2072c2555e8b8dde297a8f830. Requesting changes because the new accuracy_level=4 policy still selects fp32 when both 4-bit kernels are available, so the per-model int8 opt-in does not work as documented. A second inline comment is a non-blocking suggestion to keep per-call QNBit scratch arena-backed.
The packing/finalization, shared-buffer metadata, bias handling, and non-nested thread-pool usage otherwise follow the existing contracts. The earlier 8-bit route-coverage and zero-point-comment suggestions are addressed at this head.
Validation: source review and a source-verified dispatch-policy model; no local C++ tests or benchmarks were run because no provider-test executable was available. CI was still running at review time.
…call workspace arena-backed accuracy_level=4 (or the int8 env override) now selects the int8 kernels ahead of fp32 when both are available, as MatMulNBits does; fp32 stays the fallback when int8 is missing and the explicit fp32 override still pins it. A regression test compares raw outputs so level 4 is proven to run the int8 kernels rather than merely meeting the looser tolerance. The QNBit scratch buffer no longer uses the reserve allocation path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…elper Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Levels other than 4 and the fp32 override must match the 4-bit default bitwise; the 0 override keeps the previous paths (never pre-packing 8-bit experts); the shared-prepack case asserts both weights pre-packed instead of returning early. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Thanks, both addressed in
All |
There was a problem hiding this comment.
Reviewed at bc5f446b. The earlier findings on int8 route proof, zero-point documentation, accuracy_level=4 selection, and arena-backed QNBit workspace are addressed. The packing, shared-buffer compatibility, overflow checks, and non-nested thread-pool use follow the established MatMulNBits/MLAS contracts, and I found no new actionable issues.
Validation: rebuilt onnxruntime_provider_test from this head and ran all 16 QMoETest_CPU_Int4_BlockWise* / QMoETest_CPU_Int8_BlockWise* tests; all passed.
### Description Follow-up to #32644. That PR put block-wise int4/int8 QMoE CPU experts on the MLAS QNBit (MatMulNBits) kernels, but still issues one `MlasQNBitGemmBatch` call per active expert, so a layer costs `2 * num_active_experts` thread-pool barriers. MLAS already flattens a batch into a single parallel domain over (expert × N-block), so this batches the active experts instead: they are bucketed by token count and each bucket runs as one batched dispatch. Decode (one token per active expert) collapses to a single bucket, turning `2 * top_k` dispatches per layer into 2. This only applies where the expert loop is already serialized (fewer active experts than threads). With at least as many active experts as threads, one expert per thread remains faster and that path is unchanged. ### Key changes - Bucket the active experts by token count and run one batched `MlasQNBitGemmBatch` per bucket for FC1 and FC2, with SwiGLU applied across all gathered rows in between. - fp32 bias is passed straight from the initializer; only fp16 bias needs a converted copy. - Tests gain an intra-op thread-count knob so the same shapes can be driven through either dispatch path. ### Motivation CPU decode of MoE models is dominated by the per-expert GEMMs. The per-expert dispatch pattern is barrier-bound rather than bandwidth-bound: it sits near 30 GB/s regardless of expert count and gets *worse* above 32 threads, while the batched form scales to 68-126 GB/s. Measured on a 96-core x64 host. End-to-end on LFM2.5-8B-A1B int4 (22 QMoE layers, hidden 2048, moe_intermediate 1792, 32 experts, top-k 4) through onnxruntime-genai `model_benchmark`, 630 tokens per mode: | Metric | before | after | |---|---|---| | Token generation | 36.5 ms/token (27.4 tok/s) | **30.3 ms/token (33.0 tok/s)** | | Prompt processing (16 tok) | 249 ms | **142 ms** | A single QMoE layer at those dimensions: 0.749 -> 0.438 ms at 32 threads. The gain scales with `threads / active_experts`, since it is barrier cost that is being removed. At top-k 4 decode: 1.20x at 8 threads, 1.38x at 16, 1.78x at 32, 2.11x at 64. With few threads relative to active experts the expert-parallel path is selected instead and nothing changes. ### Testing - `MoETest.*` passes (24 tests). - New `*_PerExpertDispatch` and `*_SingleThread` cases cap the intra-op pool so the same shapes and reference exercise the per-expert path and a single-threaded pool, checking the two dispatch paths against each other. - Verified with a temporary probe that the grouped cases really take the new path and the `PerExpertDispatch` cases really do not. - Greedy generation on LFM2.5-8B-A1B produced identical token IDs and identical text before and after. ### Note on reproducibility Bucketing sorts experts by token count, which changes the cross-expert accumulation order. Decode output is bit-identical; with multiple tokens per expert results can differ at float rounding level (observed in the 8th significant digit at 16 tokens).
Description
Problem
On CPU, QMoE runs block-wise 4-bit and 8-bit experts slowly. Before every matrix multiply it converts the expert weights back to fp32, and it gives each expert its own thread. During decode only
top_kexperts run, so most threads sit idle. On an 8B-A1B model that means about 3 tokens/s. The existing MLAS Q4 fast path doesn't solve this: it only runs on AVX-512, is off by default, and re-quantizes the weights.Solution
MatMulNBitsuses. The weights aren't re-quantized, and no fp32 copy is kept. Zero points work too, because QMoE stores them the same wayMatMulNBitsdoes.accuracy_levelattribute onQMoE, with the same meaning as inMatMulNBits:accuracy_level=4switches to int8 activations, which is faster but slightly less accurate.ORT_QMOE_CPU_QNBIT_GEMMtakes precedence over the attribute.fp32forces fp32 activations, so 8-bit experts go back to the old path.int8forces int8 activations.0turns the new path off.Testing
New
MoETest.QMoETest_CPU_Int4_BlockWise_*andQMoETest_CPU_Int8_BlockWise_*tests compare against an fp32 reference. They cover decode, prefill, bias with block size 64, fp16,accuracy_level=4, zero points, each environment variable setting, and shared pre-packed weights across two sessions. AllMoETest.*andMatMulNBits*tests pass inonnxruntime_provider_teston Linux x64 (AVX-512).Model benchmark:
InferenceSession.run.0(old path)int8(accuracy_level=4)An earlier int8-only run on an M3 Ultra gave:
The fp32 default hasn't been measured on Apple Silicon yet. There it uses the NEON fp32 kernels, because KleidiAI only provides int8.
Motivation and Context
This makes block-wise quantized MoE models fast enough for CPU decode by reusing the
MatMulNBitskernels instead of converting weights back to fp32 on every call. fp32 activations stay the default where MLAS supports them because int8 loses a little accuracy. Models can opt in to int8 withaccuracy_level=4, as withMatMulNBits. This pairs with microsoft/onnxruntime-genai#2575, which exports LFM2-MoE models in this format.🤖 Generated with Claude Code