Skip to content

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

Merged
Tianlei Wu (tianleiwu) merged 11 commits into
microsoft:mainfrom
ykhrustalev:ykhrustalev/qmoe-cpu-qnbit-gemm
Sep 17, 2026
Merged

Tianlei Wu (tianleiwu) merged 11 commits into
microsoft:mainfrom
ykhrustalev:ykhrustalev/qmoe-cpu-qnbit-gemm

Conversation

@ykhrustalev

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

Copy link
Copy Markdown
Contributor

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_k experts 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

  • Pack weights once at load time. Block-wise 4-bit and 8-bit experts are packed for the same MLAS kernels that MatMulNBits uses. The weights aren't re-quantized, and no fp32 copy is kept. Zero points work too, because QMoE stores them the same way MatMulNBits does.
  • Use the threads better. When fewer experts are active than there are threads, experts run one after another and each matrix multiply gets the whole thread pool.
  • New accuracy_level attribute on QMoE, with the same meaning as in MatMulNBits:
    • 4-bit: the default keeps fp32 activations. accuracy_level=4 switches to int8 activations, which is faster but slightly less accurate.
    • 8-bit: MLAS has no fp32 kernel, so int8 activations are used at every level.
  • Environment override: ORT_QMOE_CPU_QNBIT_GEMM takes precedence over the attribute.
    • fp32 forces fp32 activations, so 8-bit experts go back to the old path.
    • int8 forces int8 activations.
    • 0 turns the new path off.
    • Any other value is an error.
  • Shared pre-packed weights across sessions. The packed buffer is tagged with how it was packed. A buffer packed differently is rejected instead of being used.
  • Fallback warning. If a block-wise node can't use the new path, a warning with the reason is logged once, because the old path is about 10x slower for decode.
  • Unchanged: row-wise scales, 2-bit weights, and other execution providers.

Testing

  • New MoETest.QMoETest_CPU_Int4_BlockWise_* and QMoETest_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. All MoETest.* and MatMulNBits* tests pass in onnxruntime_provider_test on Linux x64 (AVX-512).

  • Model benchmark:

    • Model: LFM2.5-8B-A1B (32 experts, top-4, int4 with block size 32), exported with feat: add LFM2-MoE support onnxruntime-genai#2575.
    • Machine: Xeon 8358, 30 threads, same build for every row.
    • Decode: genai greedy generation.
    • Prefill: a single InferenceSession.run.
    • Accuracy: KL divergence against the Hugging Face fp32 logits on an 81-token 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 (old path) 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

    An earlier int8-only run on an M3 Ultra gave:

    • Decode: 4 → 125 tok/s.
    • Prefill (256 tokens): 190 → 855 tok/s.
    • KL divergence: 0.112 → 0.101.

    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 MatMulNBits kernels 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 with accuracy_level=4, as with MatMulNBits. This pairs with microsoft/onnxruntime-genai#2575, which exports LFM2-MoE models in this format.

🤖 Generated with Claude Code

…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.

Comment thread onnxruntime/contrib_ops/cpu/moe/moe_quantization_cpu.cc Outdated
Comment thread onnxruntime/contrib_ops/cpu/moe/moe_quantization_cpu.cc Outdated
Comment thread onnxruntime/contrib_ops/cpu/moe/moe_quantization_cpu.cc Outdated
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
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.
@ykhrustalev

Copy link
Copy Markdown
Contributor Author

Thanks — addressed all three.

  • 8-bit default: correct, MLAS has no SQNBIT_CompFp32 variant for 8 bits (only SQ8BitGemmVariant_CompInt8), so the old policy silently kept 8-bit experts on dequantize+SGEMM. The selection now falls back to int8 when the bit width has no fp32 kernel and fp32 was not explicitly forced; ORT_QMOE_CPU_QNBIT_GEMM=fp32 still pins the dequantize path. QMoETest_CPU_Int8_BlockWise_SwiGLU now exercises the QNBit path (its tolerance moved to 0.05, matching the other int8-activation cases — the previous 0.01 was exceeded by 0.0003), and a new ..._ForcedFp32FallsBackToDequantize case pins the fallback at the tighter tolerance.
  • Packed-buffer sizing: now SafeInt<size_t>, matching the packed-cache path.
  • Pack pool: sized by min(num_experts, physical cores), so it is skipped entirely for a single expert.

All 17 MoETest.* cases pass on macOS arm64 (M3 Ultra).

@tianleiwu Tianlei Wu (tianleiwu) 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.

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.

Comment thread onnxruntime/test/contrib_ops/moe_test.cc Outdated
Comment thread onnxruntime/contrib_ops/cpu/moe/moe_quantization_cpu.h Outdated
…check, assert pre-packing in 8-bit QNBit tests

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

@tianleiwu Tianlei Wu (tianleiwu) 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.

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.

Comment thread onnxruntime/contrib_ops/cpu/moe/moe_quantization_cpu.cc Outdated
Comment thread onnxruntime/contrib_ops/cpu/moe/moe_quantization_cpu.cc Outdated
…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>
@ykhrustalev

Copy link
Copy Markdown
Contributor Author

Thanks, both addressed in acebeebc8d:

  • accuracy_level=4: int8 is now selected ahead of fp32 when available, fp32 remains the fallback, and the explicit fp32 override still pins it. QMoETest_CPU_Int4_BlockWise_SwiGLU_AccuracyLevel4SelectsInt8 asserts level-4 outputs are bitwise identical to forced int8 and differ from forced fp32; it fails against the previous fp32-first ordering.
  • Arena: the QNBit scratch allocation no longer uses reserve.

All MoETest.* pass locally on macOS arm64.

@tianleiwu Tianlei Wu (tianleiwu) 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.

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.

@tianleiwu
Tianlei Wu (tianleiwu) merged commit c18808b into microsoft:main Sep 17, 2026
90 checks passed
@ykhrustalev
Yuri Khrustalev (ykhrustalev) deleted the ykhrustalev/qmoe-cpu-qnbit-gemm branch September 17, 2026 10:15
Tianlei Wu (tianleiwu) added a commit that referenced this pull request Sep 17, 2026
### 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).
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.

3 participants