Skip to content

Add float zero point support for 2-bit LUT GEMM in MatMulNBits#28354

Merged
vraspar merged 8 commits into
mainfrom
vraspar/matmulnbits-float-zp
May 13, 2026
Merged

Add float zero point support for 2-bit LUT GEMM in MatMulNBits#28354
vraspar merged 8 commits into
mainfrom
vraspar/matmulnbits-float-zp

Conversation

@vraspar

@vraspar vraspar commented May 4, 2026

Copy link
Copy Markdown
Contributor

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 via dequant = (q - 1.5) * scale. The zero point 1.5 cannot be represented as a packed uint8 value. The existing LUT GEMM packing API only accepted uint8_t* zero points, and the fallback dequant path crashed with ORT_ENFORCE(nbits_ == 4) when encountering 2-bit + float ZP.

Changes

MLAS layer — Widened MlasLutGemmPack() to accept const void* QuantBZeroPoint + bool IsFloatZeroPoint, following the existing MlasQNBitGemmPackQuantBData convention. The AVX2 packer reads float ZP values directly per quantization group when IsFloatZeroPoint is set, computing the same (zp - midpoint) * scale correction 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_==4 enforce, 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

  • 72 MLAS LUT GEMM tests pass (including 36 new float ZP tests)
  • 13 provider-level 2-bit tests pass (including new QAD float ZP tests)
  • No regressions in existing uint8 ZP tests
  • lintrunner clean

Files changed

File Change
core/mlas/inc/mlas_qnbit.h API: void* ZP + IsFloatZeroPoint flag
core/mlas/lib/qlutgemm.h Dispatch typedef update
core/mlas/lib/qlutgemm.cpp Pass-through plumbing
core/mlas/lib/sqnbitgemm_lut_kernel_avx2.cpp Float ZP packing branch
contrib_ops/cpu/quantization/matmul_nbits.cc PrePack guard, fallback fix, ZP validation
test/mlas/unittest/test_sqlutgemm.cpp Float ZP MLAS tests
test/mlas/bench/bench_lutgemm.cpp Updated call signature
test/contrib_ops/matmul_2bits_test.cc Float ZP provider tests

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.

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 MatMulNBits kernel 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 when zero_points is dynamic. Because LUT packing only uses TryGetConstantInput and has no input_idx == zero_points pack 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-float zero-point tensor is MLFloat16. The schema also allows bfloat16, 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 checks MlasIsLutGemmAvailable(), 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.

Comment thread onnxruntime/contrib_ops/cpu/quantization/matmul_nbits.cc
Comment thread onnxruntime/contrib_ops/cpu/quantization/matmul_nbits.cc Outdated
Comment thread onnxruntime/contrib_ops/cpu/quantization/matmul_nbits.cc Outdated
Comment thread onnxruntime/test/contrib_ops/matmul_2bits_test.cc
Comment thread onnxruntime/contrib_ops/cpu/quantization/matmul_nbits.cc
Comment thread onnxruntime/contrib_ops/cpu/quantization/matmul_nbits.cc
@vraspar vraspar requested a review from Copilot May 5, 2026 18:59

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.

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.

Comment thread onnxruntime/contrib_ops/cpu/quantization/matmul_nbits.cc
Comment thread onnxruntime/contrib_ops/cpu/quantization/matmul_nbits.cc
Comment thread onnxruntime/contrib_ops/cpu/quantization/matmul_nbits.cc
Comment thread onnxruntime/contrib_ops/cpu/quantization/matmul_nbits.cc
Comment thread onnxruntime/core/mlas/lib/sqnbitgemm_lut_kernel_avx2.cpp Outdated
Comment thread onnxruntime/core/mlas/inc/mlas_qnbit.h Outdated
Comment thread onnxruntime/test/contrib_ops/matmul_2bits_test.cc
Comment thread onnxruntime/test/contrib_ops/matmul_2bits_test.cc
Comment thread onnxruntime/test/contrib_ops/matmul_2bits_test.cc
Comment thread onnxruntime/contrib_ops/cpu/quantization/matmul_nbits.cc

@vraspar vraspar left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@vraspar vraspar marked this pull request as ready for review May 6, 2026 17:53
@vraspar vraspar requested a review from Copilot May 6, 2026 17:57
@vraspar vraspar requested a review from tianleiwu May 6, 2026 21:22

@tianleiwu 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.

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.

Comment thread onnxruntime/contrib_ops/cpu/quantization/matmul_nbits.cc
Comment thread onnxruntime/contrib_ops/cpu/quantization/matmul_nbits.cc Outdated
Comment thread onnxruntime/core/mlas/lib/sqnbitgemm_lut_kernel_avx2.cpp
Comment thread onnxruntime/test/contrib_ops/matmul_2bits_test.cc

@tianleiwu 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.

More inline comments...

Comment thread onnxruntime/contrib_ops/cpu/quantization/matmul_nbits.cc
Comment thread onnxruntime/core/mlas/lib/sqnbitgemm_lut_kernel_avx2.cpp Outdated
- 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 vraspar left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_tsize_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 % BlkLen guard prevents the bug; no known model needs non-aligned K with LUT GEMM.
  • MLFloat16 LUT GEMM support: MatMulNBits<MLFloat16>::PrePack exits 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.

@vraspar vraspar requested a review from tianleiwu May 11, 2026 20:05

@tianleiwu 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.

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.

Comment thread onnxruntime/test/contrib_ops/matmul_2bits_test.cc
Comment thread onnxruntime/test/contrib_ops/matmul_2bits_test.cc
@vraspar vraspar requested a review from tianleiwu May 12, 2026 23:37

@tianleiwu 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.

Review Summary

The implementation is correct and well-structured. No correctness, security, or performance issues found.

Highlights:

  • MLAS API wideningconst void* QuantBZeroPoint + bool IsFloatZeroPoint is clean type erasure. All callers updated consistently.
  • K % BlkLen guard — The new MlasIsLutGemmAvailable() check that rejects K % BlkLen != 0 closes 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 with if constexpr ensures zero runtime overhead in the tight packing loop.
  • Compute path safety — Nulling zero_points when prefer_lut_gemm_ && packed_b_ prevents null dereference in CheckInputs. 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 VaryingPerBlock test
  • Dynamic ZP test concern → addressed by DynamicZP test with is_initializer=false
  • MLFloat16 test concern → addressed by MLFloat16_2b_MLFloat16ZP_Fallback test

@vraspar vraspar enabled auto-merge (squash) May 13, 2026 17:34
@vraspar vraspar merged commit e8ae6ce into main May 13, 2026
91 of 95 checks passed
@vraspar vraspar deleted the vraspar/matmulnbits-float-zp branch May 13, 2026 19:12
tianleiwu added a commit that referenced this pull request May 25, 2026
… 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>
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