Skip to content

[muilt-vetor] Add standalone MinMax8 x MinMax4 MaxSim kernel - #1494

Open
juchen-ms (partychen) wants to merge 2 commits into
microsoft:mainfrom
partychen:minmax_kernel
Open

juchen-ms (partychen) wants to merge 2 commits into
microsoft:mainfrom
partychen:minmax_kernel

Conversation

@partychen

@partychen juchen-ms (partychen) commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor
  • Does this PR have a descriptive title that could go in our release notes?
  • Does this PR add any new dependencies?
  • Does this PR modify any existing APIs?
  • Is the change to the API backwards compatible?
  • Should this result in any changes to our documentation, either updating existing docs or adding new ones?

No new dependencies. Public additions are backward-compatible: dense BitSlice bulk decoding and supporting SIMD trait implementations. Existing distance APIs and canonical MinMax storage remain unchanged.

Reference Issues/PRs

Reworks the matrix-kernel portion of #1394 into a standalone implementation. Uses the SIMD reinterpretation and maddubs support from #1445 and #1479.

What does this implement/fix? Briefly explain your changes.

Adds an internal MaxSim matrix kernel for MinMax A/B inputs with 1-8 bits per code, supporting all 64 bit-width combinations through the available numeric layouts.

Execution

  • PackedA owns the prepacked query codes and compensation coefficients and can be reused across B comparisons.
  • The existing Drive -> PanelKernel -> MicroKernel structure decodes each B tile once and reuses it across A panels. Compensation precedes max reduction; no full pairwise score matrix is materialized.
  • B preparation uses shared dense BitSlice::decode_into, rather than MinMax-local 4/8-bit decoding branches. Output length is validated before writing or invoking the conversion callback.
  • B rows are zero-padded to PACK. Physical stride is separate from logical K, which controls compensation and accumulation limits. Broadcasts read complete packs without a valid argument or separate partial-K contraction.
  • ExtraWide exposes backend operations for loads, accumulation, score storage, and compensated reduction. Concrete array conversions replace the generic microkernel's lane-count const assertions. Construction-time checks for legal NR/backend bit widths remain.

Available layouts

All layouts support A_BITS=1-8. MR counts A rows per microkernel; NR is a separate parameter in 1-8. These are supported layouts, not a universal optimal configuration or automatic selection policy.

Backend/path MR PACK Prepared A / B B_BITS
Scalar byte 16 4 u8 / i8 1-7
Scalar general 8, 16 2 i16 / i16 1-8
V3 byte 8, 16 4 u8 / i8 1-6
V3 general 8, 16 2 i16 / i16 1-8
V4 byte 16 4 u8 / i8 1-7
V4 general 16 2 i16 / i16 1-8
Neon + dotprod 8, 16 4 u8 / u8 1-8

The byte limits prevent signed-byte misinterpretation and, on V3, saturation of the intermediate i16 sum. Full-range eight-bit B uses the i16 path on Scalar/x86 and unsigned byte dots on Neon.

Behavior

Each drive overwrites its output with unnegated maximum inner products. NaNs are ignored; empty B or all-NaN candidates leave negative infinity. The internal driver requires nonempty A, positive K, and matching dimensions. K is bounded by i32::MAX / ((2^A_BITS - 1) * (2^B_BITS - 1)); conversion to f32 may still round.

Tests separately cover decoding, packing, arithmetic limits, supported bit widths, partial panels, tile reuse, smaller MR layouts, compensation, and NaN reduction. Repeated whole-kernel combinations have been reduced in favor of focused cases.

Any other comments?

Scope: Factory/ISA selection policy, public prepared-query distance integration, FFI/externally owned query views, and benchmark integration remain follow-up work. PackedA stays pub(crate).

Performance: MR=16 is retained alongside narrower layouts, not claimed optimal for every architecture or query size. Earlier exploratory timings and assembly observations are not validation of this head; current-head native x86 performance and spill analysis still need confirmation.

Validation at 2538cc7 -- Windows ARM64 host:

Command Result
cargo fmt --all --check Passed
cargo test -p diskann-quantization --release --lib -- bits::slice::tests matrix_kernels::maxsim::packed_minmax_x_unpacked_minmax --test-threads=1 Passed: 36 tests; native Scalar/Neon paths
cargo clippy -p diskann-quantization -p diskann-wide --all-targets -- -D warnings Passed
cargo clippy -p diskann-quantization -p diskann-wide --all-targets --target x86_64-pc-windows-msvc -- -D warnings Passed; cross-compilation/lint only

Native V3/V4 execution, SDE/Miri, and coverage were not rerun as part of these checks. Cross-compilation is not runtime validation.

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.

🔵 Needs a closer look

The new unsafe SIMD paths require cross-platform validation and final human review.

0 open findings

What changed in this PR

Adds a standalone quantized MaxSim kernel to DiskANN’s matrix kernels, leaving distance API integration for a follow-up.

Changes:

  • Implements packed-A, B-tiled computation for Scalar, V3, V4, and Neon.
  • Adds compensated inner products and NaN-aware maximum reduction.
  • Adds supporting SIMD conversions and regression coverage.
File Description
diskann-wide/​src/​reference.rs Adds reference u32-to-f32 conversion.
diskann-wide/​src/​emulated.rs Adds and tests 16-lane i32-to-f32 conversion.
diskann-wide/​src/​arch/​x86_64/​v4/​conversion.rs Adds and tests AVX-512 conversion.
diskann-wide/​src/​arch/​x86_64/​v3/​conversion.rs Adds and tests split-vector conversion.
diskann-wide/​src/​arch/​aarch64/​u32x4_.rs Adds and tests Neon conversion and reinterpretation.
diskann-quantization/​src/​matrix_kernels/​maxsim/​packed_minmax8_x_unpacked_minmax4.rs Implements the kernel, backends, and regression tests.
diskann-quantization/​src/​matrix_kernels/​maxsim/​mod.rs Registers the kernel pending integration.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@codecov-commenter

Codecov Comments Bot (codecov-commenter) commented Oct 15, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.50000% with 32 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.13%. Comparing base (18c9cce) to head (2538cc7).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
..._kernels/maxsim/packed_minmax_x_unpacked_minmax.rs 97.77% 26 Missing ⚠️
diskann-quantization/src/bits/slice.rs 97.22% 3 Missing ⚠️
diskann-wide/src/reference.rs 0.00% 3 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #1494      +/-   ##
==========================================
+ Coverage   90.94%   92.13%   +1.18%     
==========================================
  Files         586      587       +1     
  Lines      118391   121206    +2815     
==========================================
+ Hits       107675   111677    +4002     
+ Misses      10716     9529    -1187     
Flag Coverage Δ
miri 92.13% <97.50%> (+1.18%) ⬆️
unittests 92.08% <97.47%> (+1.43%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
diskann-wide/src/arch/x86_64/v3/conversion.rs 100.00% <100.00%> (ø)
diskann-wide/src/arch/x86_64/v4/conversion.rs 100.00% <ø> (+100.00%) ⬆️
diskann-wide/src/emulated.rs 100.00% <ø> (ø)
diskann-quantization/src/bits/slice.rs 97.31% <97.22%> (-0.02%) ⬇️
diskann-wide/src/reference.rs 97.27% <0.00%> (+2.88%) ⬆️
..._kernels/maxsim/packed_minmax_x_unpacked_minmax.rs 97.77% <97.77%> (ø)

... and 53 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Support all 1-8 bit A/B combinations through shared packing, tiled
execution, compensation and maximum-inner-product reduction.

Retain the tuned byte backends alongside full-range i16 paths, keep
quantization widths out of the numeric microkernels, and add the
required SIMD conversions and correctness coverage.

Leave factory, fallback and public distance integration to follow-up PRs.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 74814a43-c576-493a-8783-585ef48c3f1c
@partychen juchen-ms (partychen) changed the title Add standalone MinMax8 x MinMax4 MaxSim kernel [muilt-vetor] Add standalone MinMax8 x MinMax4 MaxSim kernel Oct 16, 2026

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 Junkui. This is a simplification over the earlier PR! There still is some coupling between the lower-level kernels and some of the higher data preparation stuff.

A few other things that stand out to me:

  • It makes heavy use of post-monomorphization errors via const { assert!(...) }. These aren't necessarily wrong, but they are kind of hard to debug when they trigger, don't compose with higher level code well, and to me often indicate that there is another way of structuring the code so they aren't needed. I'm not saying get rid of them, but that over reliance could indicate a missing abstraction.
  • Why are 16-wide accumulators used for all architectures? This seems like it could be problematic for Neon or Scalar. Have you checked that the generated code isn't spilling too much and is reasonable?

I know a few other folks are planning on reviewing this as well - let's see what they have to say.

for (i, out) in dst.iter_mut().enumerate() {
*out = W::convert_b(codes.get(i).expect("input and output lengths match") as u8);
}
}

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.

I played around with the assembly - this does result in better code generation than pulling items directly out of the BitSlice, and that is unfortunate.

However, I do not like that this reinvents unpacking. When we find things like this, I would really prefer we create an issue to fix problems at the root (in this case, improving extraction from bitslice with iterators or something) rather than patching around it locally and forgetting. The former benefits the entire code base.

As much as it pains me, please don't special case certain bit-widths and let's see if we can improve BitSlice instead.

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.

Moved bulk decoding into BitSlice::decode_into and removed the local bit-width special cases. All widths now use the same decoding algorithm.

Unsigned: Representation<A_BITS>,
{
codes: BlockTransposed<T, MR, PACK>,
meta: Coefficients<Vec<f32>>,

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.

This is a bit of a digression from the f32, f16 and i8 cases where the query layout is owned externally. This does make it a bit harder to potentially reuse, especially in an FFI context. But if this remains pub(crate), it's probably fine for now.

@partychen juchen-ms (partychen) Oct 17, 2026 •

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.

Keeping PackedA as pub(crate) for now. I agree that exposing this publicly or through FFI would warrant revisiting the ownership boundary.

}
)+};
}
remainder!(1, 2, 3, 4, 5, 6, 7);

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.

This seems aggressive even when NR == 2. Does dead code elimination eliminate the extra remainders?

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.

Added explicit $n < NR guards. These are compile-time constants, so for NR == 2, only the one-column remainder is reachable.

const {
assert!(W::Float::LANES == MR);
assert!(W::Wide::LANES == MR * PACK);
}

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.

This const-asserts something that the other micro-kernels ensure by construction. This is in part because the heavy use of constraints on associated types on ExtraWide.

Not necessarily a blocker, but it does make me wonder if there is a better decomposition.

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.

Refactored ExtraWide around backend operations. Concrete array conversions now establish load/store sizes, replacing the generic lane assertions.

}

// SAFETY: Float has exactly MR lanes, and C is an MR-element array.
let mut best = unsafe { W::Float::load_simd(self.arch, self.c.as_ptr()) };

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.

I did some cursory inspection of the V3 generated code - this seems to spill pretty heavily to the stack. Is that intentional?

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.

We tried smaller tiles, delayed coefficient loads, and different compensation schedules. Some reduced spilling, but the performance gains were modest or inconsistent and came with additional complexity.

Among the variants we tested, the current implementation offers the better balance of performance and simplicity, though compensation still spills. Do you have any suggestions for a different approach worth exploring?

///
/// `valid <= PACK` and the first `valid` elements of B must be readable.
#[inline(always)]
unsafe fn group<T: Copy + Default, const PACK: usize>(b: Slice<'_, T>, valid: usize) -> [T; PACK] {

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.

Since we're preparing B anyways, couldn't we add a little bit of zero padding and remove the need for this?

In the main body of the microkernel, all of this is constant propagated and inlined, but we could turn it into a non-issue entirely.

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.

B rows are now zero-padded to PACK during preparation. The kernel uses one loop over all padded packs; compensation still uses the logical K.

const MR: usize,
const NR: usize,
const PACK: usize,
>(

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.

What is this testing? PreparedA doesn't have a lifetime and is treated as const, so why would reuse not work?

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.

Agreed—removed the redundant reuse test. Kept the tests that verify packed codes, metadata, and padding.


#[cfg(target_arch = "x86_64")]
#[test]
fn avx2_supports_all_bit_pairs_and_byte_layouts() {

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.

This test and corresponding scalar tests are now the longest running tests when running code coverage. Is this really necessary? Can't more directed tests be used?

@partychen juchen-ms (partychen) Oct 17, 2026 •

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.

Replaced the Cartesian sweep with directed tests for packing, supported B widths, and boundaries. Local coverage-instrumented runtime dropped from 10.31s to 0.95s, with no loss of covered production lines at that cleanup stage.

}
}

fn assert_nan_candidates_preserve_maxima<

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.

This seems like quite an expensive way to check that SIMDMinMax is used properly in the final data preparation.

@partychen juchen-ms (partychen) Oct 17, 2026 •

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.

Replaced the full-driver fixtures with small, direct microkernel tests covering NaN candidates, mixed lanes, and preservation of existing maxima.

@partychen
juchen-ms (partychen) force-pushed the minmax_kernel branch 2 times, most recently from 75183b4 to e360602 Compare October 17, 2026 07:35
Move bulk decoding into BitSlice and zero-pad prepared B rows for a unified pack loop. Decouple backend representations behind fixed-size operations, retain smaller row-panel choices, and replace redundant test sweeps with directed coverage.

Make B preparation independent of backend and row-panel types by accepting a code converter. Keep backend capability validation at the Driver construction boundary.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@partychen

Copy link
Copy Markdown
Contributor Author

Thanks Junkui. This is a simplification over the earlier PR! There still is some coupling between the lower-level kernels and some of the higher data preparation stuff.

A few other things that stand out to me:

  • It makes heavy use of post-monomorphization errors via const { assert!(...) }. These aren't necessarily wrong, but they are kind of hard to debug when they trigger, don't compose with higher level code well, and to me often indicate that there is another way of structuring the code so they aren't needed. I'm not saying get rid of them, but that over reliance could indicate a missing abstraction.
  • Why are 16-wide accumulators used for all architectures? This seems like it could be problematic for Neon or Scalar. Have you checked that the generated code isn't spilling too much and is reasonable?

I know a few other folks are planning on reviewing this as well - let's see what they have to say.

Thanks for the feedback. I’ve moved SIMD-specific operations behind ExtraWide, removing the lane-count const assertions from the generic microkernel. The arithmetic-safety checks remain.

I’ve also added MR=8 alternatives for Neon, V3, and Scalar’s i16 path rather than requiring 16-wide accumulators everywhere. Earlier V3 assembly inspection showed register-resident accumulators in the full contraction loop, but stack traffic around compensation and tails; I’m not claiming all configurations are spill-free.

PackedA remains pub(crate) for now. I’ll incorporate the other reviewers’ feedback as well.

This branch has not been deployed

No deployments
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.

4 participants