Add float zero point support for 2-bit LUT GEMM in MatMulNBits#28354
Conversation
There was a problem hiding this comment.
Pull request overview
Adds float/float16 zero-point handling for the CPU 2-bit MatMulNBits path, primarily by widening the MLAS LUT pack API and teaching the CPU kernel to route/convert float zero points for LUT prepack and unpacked fallback dequantization.
Changes:
- Extends MLAS LUT GEMM packing APIs and AVX2 packing logic to accept float zero-point data.
- Updates the CPU
MatMulNBitskernel to allow LUT prepack with unquantized zero points and adds 2-bit float/FP16 fallback dequantization. - Adds MLAS/unit, provider, and benchmark call-site updates for the new LUT pack signature and float-ZP scenarios.
Findings
onnxruntime/contrib_ops/cpu/quantization/matmul_nbits.cc: relaxing the early exit for unquantized zero points now lets the LUT prepack path run even whenzero_pointsis dynamic. Because LUT packing only usesTryGetConstantInputand has noinput_idx == zero_pointspack step, dynamic float/float16 zero points will be ignored in the packed path.onnxruntime/contrib_ops/cpu/quantization/matmul_nbits.cc: both new zero-point conversion blocks assume every non-floatzero-point tensor isMLFloat16. The schema also allowsbfloat16, so constant BF16 zero points would be reinterpreted as FP16 during prepack.onnxruntime/test/contrib_ops/matmul_2bits_test.cc: the new provider test never checksMlasIsLutGemmAvailable(), so on platforms without LUT GEMM it can pass via the unpacked fallback and fail to validate the new LUT float-ZP path.onnxruntime/contrib_ops/cpu/quantization/matmul_nbits.cc: the new float zero-point 2-bit fallback dequant branch is not covered by the added tests, because the new tests force LUT GEMM on LUT-compatible shapes.onnxruntime/contrib_ops/cpu/quantization/matmul_nbits.cc: the new MLFloat16-specific 2-bit float zero-point fallback branch also lacks coverage; the added tests only exercise float inputs/zero points.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
onnxruntime/test/mlas/unittest/test_sqlutgemm.cpp |
Adds MLAS float-zero-point LUT GEMM unit coverage and new pack call signature usage. |
onnxruntime/test/mlas/bench/bench_lutgemm.cpp |
Updates benchmark call sites for the widened LUT pack API. |
onnxruntime/test/contrib_ops/matmul_2bits_test.cc |
Adds provider-level float zero-point 2-bit tests. |
onnxruntime/core/mlas/lib/sqnbitgemm_lut_kernel_avx2.cpp |
Implements AVX2 packing support for float zero points. |
onnxruntime/core/mlas/lib/qlutgemm.h |
Updates LUT dispatch typedef to carry generic ZP pointer + type flag. |
onnxruntime/core/mlas/lib/qlutgemm.cpp |
Threads the new zero-point arguments through MLAS LUT pack plumbing. |
onnxruntime/core/mlas/inc/mlas_qnbit.h |
Updates public MLAS LUT pack declaration/docs for float zero points. |
onnxruntime/contrib_ops/cpu/quantization/matmul_nbits.cc |
Adjusts CPU prepack/compute logic for float zero points in LUT and fallback paths. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated 11 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
vraspar
left a comment
There was a problem hiding this comment.
Review: Add float zero point support for 2-bit LUT GEMM in MatMulNBits
Overall: Clean, well-scoped PR. The math is correct, the API extension is pragmatic, and test coverage is solid. Two items worth addressing:
1. Test reference uses wrong packed stride (both test files)
matmul_2bits_test.cc -- RunTest2BitsFloatZP:
cpp int64_t packed_idx = n * (K / 4) + k / 4; // assumes K == packed_k
test_sqlutgemm.cpp -- TestFloatZeroPoint:
cpp size_t packed_idx = n * (K / (8 / BlkBitWidth)) + k / (8 / BlkBitWidth); // same issue
MlasQuantizeBlockwise pads to packed_k = k_blocks * block_size. The kernel correctly uses the padded stride (packed_k / 4), and the fallback test (Float32_2b_FloatZP_Fallback) gets it right too. These two references diverge from the kernel when K is not a multiple of block_size. All current test cases happen to use aligned K, so it is not a live failure -- but it would produce wrong reference values if someone adds RunTest2BitsFloatZP(1, 128, 100, 32, 1.5f). Suggest using the same packed_k-based stride and adding one non-aligned-K test case to prevent regression.
2. Duplicated ZP conversion in PrePack -- extract helper
The ~25-line fp16-to-fp32 ZP conversion + validation block is copy-pasted in two PrePack code paths (input_idx == B and input_idx == scales under prefer_lut_gemm_). If bfloat16 ZP or other changes come later, both copies need updating. Consider extracting into a small helper method or local lambda.
tianleiwu
left a comment
There was a problem hiding this comment.
Review Summary
The approach is sound: widening the MLAS API to accept void* ZP with a type flag, scalar fallback for 2-bit float ZP, and properly nulling zero_points in Compute() when LUT GEMM has consumed them. The ConvertFloatZeroPointsForLutGemm helper and the compute_zp_correction lambda in the AVX2 kernel are clean abstractions.
However, the cross-type ZP issue from the previous review round remains unaddressed: in ComputeBUnpacked<float>, an MLFloat16 zero-point tensor enters the uint8 cast path, producing garbage. Additionally, there's still no test coverage for the MLFloat16 ZP paths that this PR adds code for.
Overall quality is good — well-structured, good test coverage of the primary path, and clear commit messages explaining the progression of fixes.
- Add K%BlkLen==0 guard to MlasIsLutGemmAvailable to prevent pre-existing floor-div stride mismatch in the AVX2 packer - Guard dynamic (non-constant) ZP + LUT GEMM: skip LUT packing when ZP is not a constant initializer, fall back to unpacked dequant path (similar to KleidiAI's dynamic ZP fallback) - Template IsFloatZeroPoint in PackScalesAndZeroPoints_avx2_impl for compile-time branch elimination via if constexpr - Change int32_t to size_t in 2-bit fallback dequant loops for consistency with MLAS conventions and overflow safety - Add clarifying comments at ComputeBUnpacked ZP dispatch points explaining T3 kernel type constraint prevents cross-type ZP - Add MLFloat16 activation + MLFloat16 ZP fallback test covering the ComputeBUnpacked<MLFloat16> 2-bit dequant path Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
vraspar
left a comment
There was a problem hiding this comment.
Addressing review feedback (commit 004beb1)
Pushed fixes for all outstanding review items. Summary:
Fixes applied
| Change | Addresses |
|---|---|
K % BlkLen == 0 guard in MlasIsLutGemmAvailable |
Thread by @tianleiwu re stride consistency — the AVX2 packer uses floor division (K / BlkLen) which diverges from caller's ceiling division when K % BlkLen != 0. This is pre-existing (affects uint8 ZP too) but newly exposed. Guard is the safe minimal fix; full arbitrary-K support is a separate MLAS effort. |
| Dynamic ZP guard in PrePack | Own finding — if ZP is a non-constant input, has_zp_input_ is false, LUT packs without ZP, and Compute() nulls ZP → silent wrong results. Added has_zp_arg_ member + early return, similar to KleidiAI's dynamic ZP fallback (lines 377-386). |
int32_t → size_t in fallback dequant loops |
Thread 18 by @tianleiwu — consistent with MLAS conventions, prevents overflow for very large models. |
| T3 constraint comments at ZP dispatch points | Thread 17 by @tianleiwu — the cross-type ZP scenario (MLFloat16 ZP with float activations) cannot happen because the kernel registration constrains T3 to {uint8_t, T1}. Added comments at the dispatch points explaining this so future readers don't need to trace through kernel binding. |
| MLFloat16 ZP fallback test | Thread 20 by @tianleiwu — new test with MLFloat16 activations + MLFloat16 zero points exercising ComputeBUnpacked<MLFloat16> 2-bit dequant path. |
Items deferred (with rationale)
- Full arbitrary-K LUT GEMM support: Requires fixing packer, sizing, and compute kernel stride math across MLAS. The
K % BlkLenguard prevents the bug; no known model needs non-aligned K with LUT GEMM. - MLFloat16 LUT GEMM support:
MatMulNBits<MLFloat16>::PrePackexits early for unquantized ZP. Adding LUT support for fp16 activations is a feature request with no current user demand; the fallback dequant path handles it correctly.
tianleiwu
left a comment
There was a problem hiding this comment.
Thanks for the follow-up updates. I rechecked the current head and did not find a new blocking correctness issue. I left two suggestion-level test coverage notes that would make the float zero-point changes harder to regress.
tianleiwu
left a comment
There was a problem hiding this comment.
Review Summary
The implementation is correct and well-structured. No correctness, security, or performance issues found.
Highlights:
- MLAS API widening —
const void* QuantBZeroPoint+bool IsFloatZeroPointis clean type erasure. All callers updated consistently. - K % BlkLen guard — The new
MlasIsLutGemmAvailable()check that rejectsK % BlkLen != 0closes a subtle floor/ceiling division mismatch between the caller and the AVX2 packer kernel. - PrePack guard logic — The split into three distinct checks (
has_g_idx_,has_unquantized_zero_point_ && !prefer_lut_gemm_,prefer_lut_gemm_ && has_zp_arg_ && !has_zp_input_) is correct. The dynamic ZP guard prevents silent zero-point ignorance when ZP is not a constant initializer. - AVX2 compile-time dispatch —
<bool HasZeroPoint, bool IsFloatZeroPoint>template withif constexprensures zero runtime overhead in the tight packing loop. - Compute path safety — Nulling
zero_pointswhenprefer_lut_gemm_ && packed_b_prevents null dereference inCheckInputs. When the dynamic ZP guard fires (no prepack),packed_b_stays null and ZP is correctly fetched from context for the scalar fallback. - Test coverage — Comprehensive: uniform ZP (QAD zp=1.5), varying per-block ZP (detects stride/transpose bugs), dynamic ZP fallback, non-aligned K, MLFloat16 ZP, MLAS-level tests across block lengths 32/64/128.
All four previously unresolved review threads have been addressed by the current head and resolved:
- Cross-typed ZP concern → correct per kernel registration constraints (T3 = {uint8_t, T1})
- Uniform ZP test concern → addressed by
VaryingPerBlocktest - Dynamic ZP test concern → addressed by
DynamicZPtest withis_initializer=false - MLFloat16 test concern → addressed by
MLFloat16_2b_MLFloat16ZP_Fallbacktest
… multi-threaded kernel (#28589) ### Description Replace the naive single-threaded scalar loop for 2-bit dequantization with float/MLFloat16 zero points with a multi-threaded kernel (`DequantizeBlockwise2Bits`) that: - **Parallelizes via `TrySimpleParallelFor`** — distributes work across all intra-op threads (previously single-threaded) - **Processes 16 elements per iteration** — one `uint32_t` = 16 packed 2-bit values, reducing per-element overhead - **Hoists scale/zp lookups** — all 16 elements share a block, so scale and zero_point are loaded once per batch Follows the same threading pattern as the existing 4-bit `DequantizeBlockwise` path for consistency. **Files changed:** - `matmul_nbits_impl.h` — declare `DequantizeBlockwise2Bits` - `matmul_nbits_impl.cc` — implement `Dequantize2BitsKernel` + `DequantizeBlockwise2Bits` with instantiations for `<float,float>` and `<float,MLFloat16>` - `matmul_nbits.cc` — replace naive loops in both `MatMulNBits<float>` and `MatMulNBits<MLFloat16>` `ComputeBUnpacked` ### Motivation and Context The `bits=2` + float zero_point path (added in #28354) was flagged with `// !!!!!!!!!!!!!! naive implementation, need to be optimized !!!!!!!!!!!!!!`. It ran ~20× slower than the `bits=4` MLAS path because it was a tight scalar `for n × for k` loop with no threading — the entire N×K dequantization ran on a single core before calling `MlasGemmBatch`. With 8 intra-op threads this should recover most of that gap. ### Benchmark Results Tested on a 96-core x86_64 Linux machine, ORT 1.27.0 CPU Release build, using typical LLM matrix shapes with `block_size=128` and float zero points. #### Multi-thread speedup (2-bit dequantization, 1 thread → 8 threads) | Shape (M×K×N) | 1-thread (ms) | 8-thread (ms) | Speedup | |---|---|---|---| | 1×4096×4096 | 41.0 | 8.5 | **4.84×** | | 32×4096×4096 | 47.9 | 8.8 | **5.46×** | | 1×4096×11008 | 120.7 | 24.2 | **4.99×** | | 32×4096×11008 | 146.8 | 28.2 | **5.21×** | | 1×11008×4096 | 119.2 | 24.5 | **4.87×** | | 32×11008×4096 | 154.4 | 28.2 | **5.47×** | | 1×1024×1024 | 1.18 | 0.16 | **7.61×** | #### 2-bit vs 4-bit comparison (ratio = 2-bit / 4-bit; <1.0 means 2-bit is faster) | Shape (M×K×N) | Threads | 4-bit (ms) | 2-bit (ms) | Ratio | |---|---|---|---|---| | 1×4096×4096 | 1 | 52.0 | 41.0 | **0.79×** | | 1×4096×4096 | 8 | 9.4 | 8.5 | **0.90×** | | 1×4096×11008 | 1 | 141.6 | 120.7 | **0.85×** | | 1×4096×11008 | 8 | 26.8 | 24.2 | **0.90×** | | 1×11008×4096 | 1 | 141.2 | 119.2 | **0.84×** | | 1×11008×4096 | 8 | 26.6 | 24.5 | **0.92×** | | 32×4096×4096 | 1 | 56.1 | 47.9 | **0.85×** | | 32×4096×4096 | 8 | 9.6 | 8.8 | **0.92×** | | 1×1024×1024 | 1 | 1.66 | 1.18 | **0.71×** | **Key findings:** - Multi-threading delivers **4.8–7.6× speedup** with 8 threads across all LLM shapes - 2-bit is now **10–30% faster** than 4-bit (ratio 0.71–0.93×), due to fewer bytes read from memory - The original ~20× regression (issue #28552) is fully resolved --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: tianleiwu <30328909+tianleiwu@users.noreply.github.com> Co-authored-by: Tianlei Wu <tlwu@microsoft.com>
Description
Adds support for float/float16 zero points in the 2-bit MatMulNBits LUT GEMM path, enabling AMD QAD/Quark 2-bit quantization which requires a fractional zero point of 1.5.
Addresses #28162
Problem
QAD 2-bit quantization uses non-uniform levels
[-1, -1/3, 1/3, 1], expressed viadequant = (q - 1.5) * scale. The zero point 1.5 cannot be represented as a packed uint8 value. The existing LUT GEMM packing API only accepteduint8_t*zero points, and the fallback dequant path crashed withORT_ENFORCE(nbits_ == 4)when encountering 2-bit + float ZP.Changes
MLAS layer — Widened
MlasLutGemmPack()to acceptconst void* QuantBZeroPoint+bool IsFloatZeroPoint, following the existingMlasQNBitGemmPackQuantBDataconvention. The AVX2 packer reads float ZP values directly per quantization group whenIsFloatZeroPointis set, computing the same(zp - midpoint) * scalecorrection stored in the packed buffer. The compute kernel (TMACComputeGemm_avx2) is unchanged — it already consumes ZP as a float correction during accumulation.MatMulNBits CPU kernel — Relaxed the PrePack early-exit guard to allow float ZP into the LUT GEMM path (not non-LUT paths). Added fp16→fp32 conversion for ZP tensors, matching how scales are already handled. Fixed the Compute() path to null out prepacked zero_points to avoid a null dereference in CheckInputs. Fixed the 2-bit fallback dequant path: relaxed the
nbits_==4enforce, added inline 2-bit scalar dequant for float and MLFloat16 ZP with correct packed-B indexing for padded K shapes.Tests — Added MLAS-level float ZP tests across block lengths 32/64/128 with ZP values {0, 1.5, 2, 3}. Added provider-level directed QAD tests (
zp=1.5) verifying end-to-end correctness through the LUT GEMM path.Testing
Files changed
core/mlas/inc/mlas_qnbit.hvoid*ZP +IsFloatZeroPointflagcore/mlas/lib/qlutgemm.hcore/mlas/lib/qlutgemm.cppcore/mlas/lib/sqnbitgemm_lut_kernel_avx2.cppcontrib_ops/cpu/quantization/matmul_nbits.cctest/mlas/unittest/test_sqlutgemm.cpptest/mlas/bench/bench_lutgemm.cpptest/contrib_ops/matmul_2bits_test.cc