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).
Updated [Microsoft.ML.OnnxRuntime](https://github.com/Microsoft/onnxruntime) from 1.30.0 to 1.31.0. <details> <summary>Release notes</summary> _Sourced from [Microsoft.ML.OnnxRuntime's releases](https://github.com/Microsoft/onnxruntime/releases)._ ## 1.31.0 ONNX Runtime 1.31.0 stabilizes model-package and EPContext data APIs, improves CPU model loading and quantized MoE inference, expands device-based execution-provider selection, and strengthens model-loading and runtime reliability. These notes cover changes since ONNX Runtime 1.30.0. CUDA and WebGPU kernel updates are summarized here; detailed provider notes are covered by their separate plugin EP releases. ## Highlights - Promoted the model-package API and EPContext data callbacks to stable C and C++ APIs, with EPContext callback support added across language bindings ([#33166](microsoft/onnxruntime#33166), [#32265](microsoft/onnxruntime#32265)). - Reduced CPU session-initialization overhead by parallelizing eligible weight prepacking, and accelerated block-wise INT4/INT8 QMoE experts with MLAS QNBit kernels and grouped expert dispatch ([#31691](microsoft/onnxruntime#31691), [#32644](microsoft/onnxruntime#32644), [#32668](microsoft/onnxruntime#32668)). - Added x86 FP16 LayerNorm/RMSNorm acceleration, Arm KleidiAI SVE2.1 FP16 GEMM support, and additional RISC-V vector kernels ([#32715](microsoft/onnxruntime#32715), [#32670](microsoft/onnxruntime#32670), [#32710](microsoft/onnxruntime#32710)). - Enabled CoreML participation in device-based EP selection, including Apple Neural Engine selection through `PREFER_NPU` ([#31975](microsoft/onnxruntime#31975)). - Added workspace-memory accounting and verification for constrained-memory graph partitioning, alongside broader model, graph, and tensor validation ([#31962](microsoft/onnxruntime#31962), [#32189](microsoft/onnxruntime#32189)). ## Announcements & Compatibility - [CUDA Plugin EP v0.3.0](https://github.com/microsoft/onnxruntime/releases/tag/plugin-ep-cuda/v0.3.0) and WebGPU Plugin EP v0.5.0 are matched with ONNX Runtime 1.31.0. For **contrib-op compatibility**, core and the plugin must use the same contributed-operator schemas; building both from the **same ONNX Runtime revision** is recommended. Schema mismatches can cause incorrect execution or crashes and may not be detected during plugin registration. - **`GatherBlockQuantized` behavior change:** out-of-range indices now produce zero output slices across CPU, CUDA, and WebGPU for both integer and floating-point quantized formats. This deliberately differs from ONNX `Gather` error semantics ([#32480](microsoft/onnxruntime#32480)). - **Experimental API migration:** model-package consumers should use `OrtApi::GetModelPackageApi` and the stable `OrtModelPackageApi` table. EPContext callback registration now uses stable APIs; the corresponding experimental lookup entries were removed. The stable native callback contract carries callback/state pointers, with payload limits enforced by application callbacks, bindings, or EPs rather than a native read-options policy ([#33166](microsoft/onnxruntime#33166), [#32265](microsoft/onnxruntime#32265)). - **ACL EP is deprecated** and will be removed in a future release. Source builds using `--use_acl` now receive a deprecation warning ([#32564](microsoft/onnxruntime#32564)). - The ONNX dependency remains at **1.22.0**. The initial ONNX 1.23 integration was reverted pending upstream fixes; this release does not ship that integration's new opset or FLOAT6 support ([#33073](microsoft/onnxruntime#33073)). - Supported native builds now default to **1DS telemetry**. Windows TraceLogging remains selectable with `--use_windows_telemetry` or `onnxruntime_USE_WINDOWS_TELEMETRY=ON`; telemetry can be disabled with `--no_telemetry` ([#32384](microsoft/onnxruntime#32384)). ## New Features ### Core APIs & Runtime - Stabilized the model-package C and C++ APIs for loading packages, selecting compatible model variants, and creating sessions ([#33166](microsoft/onnxruntime#33166)). - Stabilized named EPContext data read/write callbacks for application-managed storage. Added EP support negotiation so registered callbacks do not silently fall back to filesystem I/O ([#32265](microsoft/onnxruntime#32265)). - Extended the Compile API to write external initializers and EP context data to buffers, enabling in-memory compilation workflows for models larger than 2 GB ([#31347](microsoft/onnxruntime#31347)). - Added workspace-memory accounting, source reporting, and post-partition reservation verification. Set `session.strict_workspace_verification=1` to fail initialization on workspace declaration overruns or nonzero orphaned reservations; strict verification is not supported for ORT-format model loads ([#31962](microsoft/onnxruntime#31962), [#32189](microsoft/onnxruntime#32189)). - Added the opt-in source-build option `onnxruntime_DISABLE_DEVICE_DISCOVERY=ON` to skip platform accelerator probing and report only the CPU device ([#32592](microsoft/onnxruntime#32592)). ### Shared Contrib Operators These updates affect core operator schemas and cross-provider behavior, not only the separately released GPU plugins. - Extended `EngramGate` and `NGramHashMapping` for Qwen4-Exp, and added `VarlenNGramHashMapping` for packed DeepSeek Engram batching with request-local history. Packed hashing also supports Qwen4-Exp head offsets, EOS resets, and segment boundaries ([#32285](microsoft/onnxruntime#32285), [#32358](microsoft/onnxruntime#32358), [#32467](microsoft/onnxruntime#32467)). - Added the `activation` attribute to `GatedRMSNorm` for SiLU, its Swish alias, and Sigmoid gating across CPU, CUDA, and WebGPU. SiLU remains the default ([#32512](microsoft/onnxruntime#32512)). - Added weightless hyper-connection operators `BranchwiseRMSNorm`, `ScaledSiLU`, `HyperConnectionPreMix`, and `HyperConnectionPostMix`, with CPU, CUDA, and WebGPU implementations ([#32687](microsoft/onnxruntime#32687)). - Extended `GatherBlockQuantized` to FP8/FP4 data, full-axis blocks, and scale broadcasting, and improved CPU UINT8 dequantization. See the out-of-range-index behavior change above ([#32480](microsoft/onnxruntime#32480), [#32621](microsoft/onnxruntime#32621)). ### Plugin Execution Provider Infrastructure - Added support for statically linked plugin EPs, with a static WebGPU plugin build configuration ([#32395](microsoft/onnxruntime#32395)). - Preserved caller streams in single-tensor plugin data transfers and handled null plugin allocator returns ([#32666](microsoft/onnxruntime#32666), [#32683](microsoft/onnxruntime#32683)). - Deduplicated AutoEP custom-op domains and corrected EP assignment reporting after NHWC layout transformation ([#32711](microsoft/onnxruntime#32711), [#33184](microsoft/onnxruntime#33184)). - Added reference guidance for compiled-model compatibility validation, including multi-device scenarios ([#29168](microsoft/onnxruntime#29168)). ## Execution Provider Updates ### CUDA EP CUDA updates include sparse attention and packed continuous batching, expanded paged speculative decoding, INT2 and mixed-width QMoE execution, opt-in MatMul auto-tuning, improved MXFP4 support on Blackwell consumer GPUs, and reliability fixes. ... (truncated) Commits viewable in [compare view](microsoft/onnxruntime@v1.30.0...v1.31.0). </details> Updated [Swashbuckle.AspNetCore](https://github.com/domaindrivendev/Swashbuckle.AspNetCore) from 10.2.3 to 10.3.0. <details> <summary>Release notes</summary> _Sourced from [Swashbuckle.AspNetCore's releases](https://github.com/domaindrivendev/Swashbuckle.AspNetCore/releases)._ ## 10.3.0 ## What's Changed * Change nullability handling for schemas with `anyOf`, `allOf`, `oneOf` or `type = null` by @ldeluigi in domaindrivendev/Swashbuckle.AspNetCore#3976 * Handle leading slash on route prefix during index.html GET by @superstator in domaindrivendev/Swashbuckle.AspNetCore#4084 * Move NRT handling to DataContractResolver by @desjoerd in domaindrivendev/Swashbuckle.AspNetCore#3998 * Clarify nullable behaviour by @martincostello in domaindrivendev/Swashbuckle.AspNetCore#4094 * Fix review issues for caching and encoding by @martincostello in domaindrivendev/Swashbuckle.AspNetCore#4093 * Support multiple response types per status code by @DeagleGross in domaindrivendev/Swashbuckle.AspNetCore#4100 * Add coverage for `AddServer` across multiple documents by @RaphaelFakhri in domaindrivendev/Swashbuckle.AspNetCore#4115 * Fix Swagger sort key fallback for empty endpoint tags metadata by @AleksaMCode in domaindrivendev/Swashbuckle.AspNetCore#4125 * Fix parameter matching to consider location when names collide by @nonsoikem in domaindrivendev/Swashbuckle.AspNetCore#4126 * Emit form encoding for properties of a complex form parameter by @RaphaelFakhri in domaindrivendev/Swashbuckle.AspNetCore#4128 * Keep a property's own summary when comments come from several XML files by @RaphaelFakhri in domaindrivendev/Swashbuckle.AspNetCore#4129 * Strip query string from relative document URLs in Swagger UI by @GabeSilvaDev in domaindrivendev/Swashbuckle.AspNetCore#4152 * Document that System.Text.Json ignores `[EnumMember]` by @RaphaelFakhri in domaindrivendev/Swashbuckle.AspNetCore#4127 * Drop the regex route constraint from the default Swagger route by @RaphaelFakhri in domaindrivendev/Swashbuckle.AspNetCore#4130 * Serialize custom values in the UI `ConfigObject.AdditionalItems` by @RaphaelFakhri in domaindrivendev/Swashbuckle.AspNetCore#4132 * Use Minimal APIs JSON options when MVC is not registered by @RaphaelFakhri in domaindrivendev/Swashbuckle.AspNetCore#4116 * Resolve enum contracts against the underlying type under source generation by @RaphaelFakhri in domaindrivendev/Swashbuckle.AspNetCore#4131 ## New Contributors * @ldeluigi made their first contribution in domaindrivendev/Swashbuckle.AspNetCore#3976 * @superstator made their first contribution in domaindrivendev/Swashbuckle.AspNetCore#4084 * @desjoerd made their first contribution in domaindrivendev/Swashbuckle.AspNetCore#3998 * @RaphaelFakhri made their first contribution in domaindrivendev/Swashbuckle.AspNetCore#4115 * @AleksaMCode made their first contribution in domaindrivendev/Swashbuckle.AspNetCore#4125 * @nonsoikem made their first contribution in domaindrivendev/Swashbuckle.AspNetCore#4126 * @GabeSilvaDev made their first contribution in domaindrivendev/Swashbuckle.AspNetCore#4152 * @JulianThoms made their first contribution in domaindrivendev/Swashbuckle.AspNetCore#4182 **Full Changelog**: domaindrivendev/Swashbuckle.AspNetCore@v10.2.3...v10.3.0 Commits viewable in [compare view](domaindrivendev/Swashbuckle.AspNetCore@v10.2.3...v10.3.0). </details> Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) --- <details> <summary>Dependabot commands and options</summary> <br /> You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore <dependency name> major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore <dependency name> minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore <dependency name>` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore <dependency name>` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore <dependency name> <ignore condition>` will remove the ignore condition of the specified dependency and ignore conditions </details> Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
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