[multi vector] Add tiled MinMax MaxSim kernels - #1394
juchen-ms (partychen) wants to merge 26 commits into
Conversation
Implement MinMax8 query by MinMax4 document matrix kernels with Scalar, AVX2, AVX-512, and Neon paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Use architecture intrinsics directly in the matrix kernel and restore unrelated distance and diskann-wide changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Reuse diskann-wide operations where available and handle unsupported ISAs in tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Pack queries with grouped slice copies, build document panels in one pass, and consume accumulators while borrowing metadata. Initialize scores within each query block and extend tail coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🔵 Needs a closer look
The public factory has a Reference ISA compatibility issue, and V3/V4 runtime paths remain unvalidated on x86-64.
Pull request overview
Adds prepared, tiled MaxSim kernels for MinMax8 queries against MinMax4 documents with ISA-specific dispatch.
Changes:
- Adds public prepared-kernel and factory APIs.
- Implements Scalar, AVX2/V3, AVX-512/V4, and ARM64 Neon kernels.
- Adds packing, fused compensation/reduction, validation, and reuse tests.
File summaries
| File | Summary | Final review note |
|---|---|---|
diskann-quantization/src/multi_vector/mod.rs |
Re-exports the factory. | — |
diskann-quantization/src/minmax/multi/mod.rs |
Registers and exports kernel modules. | — |
diskann-quantization/src/minmax/multi/kernel.rs |
Defines kernel interfaces. | — |
diskann-quantization/src/minmax/multi/factory.rs |
Provides ISA dispatch and prepared-kernel construction. | Moderate: MaxSimIsa::Reference is reported available but rejected by this factory. |
diskann-quantization/src/minmax/mod.rs |
Exposes MinMax APIs. | — |
diskann-quantization/src/matrix_kernels/maxsim/mod.rs |
Registers the MinMax matrix kernel. | — |
diskann-quantization/src/matrix_kernels/maxsim/minmax8_x_minmax4.rs |
Implements packing, tiling, compensation, SIMD kernels, and tests. | Nit: V3/V4 paths lack x86-64 runtime coverage. |
Review details
Suppressed comments (2)
diskann-quantization/src/matrix_kernels/maxsim/minmax8_x_minmax4.rs:785
- The V3/V4 implementations here are only compile-checked in the stated ARM64 validation: the ISA tests return early when the requested x86 ISA is unavailable, so the
pdep/VNNI path and its lane reduction are not exercised. Please add or run an x86-64 runtime test (or an equivalent testable emulation) covering these kernels before relying on this new code.
micro_kernel!(V3, 16, micro_kernel, {8, 7, 6, 5, 4, 3, 2, 1});
micro_kernel!(V4, 16, micro_kernel, {8, 7, 6, 5, 4, 3, 2, 1});
diskann-quantization/src/minmax/multi/factory.rs:171
MaxSimIsa::Referenceis documented as an always-available selector (is_available()returns true), and the existing MinMax implementation provides the reference MaxSim path, but this new public factory rejects it unconditionally. Callers that useis_available()to preflight an ISA therefore receiveNotSupportedfor a value reported as buildable. Please add a reference adapter (or make support for this factory explicit in the ISA API/documentation).
MaxSimIsa::Reference => Err(NotSupported {
isa,
reason: "reference kernel unavailable",
}),
- Files reviewed: 7/7 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #1394 +/- ##
==========================================
+ Coverage 90.58% 90.69% +0.11%
==========================================
Files 568 574 +6
Lines 112680 114387 +1707
==========================================
+ Hits 102069 103742 +1673
- Misses 10611 10645 +34
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
Mark Hildebrand (hildebrandmw)
left a comment
There was a problem hiding this comment.
There's a lot going on here. It does add a minmax8x4 kernel, but basically builds a second matrix-kernel decomposition architecture rather than extending the existing abstractions introduced in 1368.
The point of the machinery introduced there was to use enable better higher-level experimentation of cache tilings and ordering without fully relying on Miri or checked accesses for correctness debugging. All of these pieces could then be tested, reviewed, and reused independently.
Here, packing and traversal are all reimplemented manually with inconsistent bounds tracking applied. This is hard to test, validate, or reuse.
My suggestion would be to follow the design philosophy of 1368 and build the traversal mechanism out of lower-level parts (which we can then reuse for other quantization kernels). In addition, matrix kernels should not own any non-scratch state because that also makes them much more difficult to reuse.
I also have some other design concerns:
- The microkernel is currently doing a lot, and I feel that there has to be a more efficient unpacking than doing this much bit-twiddling in general purpose registers. One design worth considering incrementally unpacking B and reusing that for the whole A traversal (flipping the cache tiling order - which again is easier to do via paneled views). This can perhaps be coupled with a different permutation strategy of the A-side query for more efficiency.
ExtraWideshould as much as possible keep the associated types opaque to the caller (likeExtraWidein the f32 kernel). This makes it significantly easier to express architecture specific optimizations of coarser grained kernels without the profoundly heaverSIMDVectorand such constraints.- How many of the Miri exceptions are really needed? Miri can emulate a good number of Neon and AVX2 intrinsics.
- Directed testing of the various steps is pretty sparse. Especially given its ad-hoc mix of checked, unchecked, and
Bounds based indexing. UsingBounds based checking in a more disciplined way at least provides higher confidence of integration-based tests.
Have you considered making the A-side packing even more aggressive, splitting into groups of even and odd indices? The idea there is that it would naturally fit the order of unpacked nibbles much better and avoid a lot of the interleaving logic. Basically, a lot the micro-kernels are concerned about restoring a dimension ordering that we anyways control.
Reuse packed and unpacked views, prepare even-odd query groups in the factory, and unpack each document tile once for all query panels. Keep SIMD register types opaque and cover packing, conversion, bounds, and kernel behavior directly. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Prefer complete document panels that fit 4 KiB of value scratch plus 32 metadata records on the stack, retaining cache-sized heap fallback for larger dimensions. Cover scratch boundaries and use the corrected 16 x 16 x 256 primary workload. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Use one Driver/PanelKernel/MicroKernel traversal for Grouped<4> and Grouped<8>, keeping packed document expansion inside the architecture backend. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Mark Hildebrand (@hildebrandmw) Thanks for the detailed review. I reworked the implementation around the abstractions from #1368 in
I also added/ran directed packing, tail, bounds, driver/register, factory-equivalence, repeated-computation, empty-input, and NaN-compensation coverage. Native x86-64 V3/V4 tests, ARM64 cross-Clippy, and the V4 Miri test pass. For the 1,000-document 16x16 workload on a Xeon Platinum 8370C, the latest release measurements are:
The 250-dimensional Existing path has a scalar remainder, so I am keeping the 250- and 256-dimensional results explicitly separate. |
Reuse bounded stack scratch across query panels through the existing Driver, PanelKernel, and MicroKernel pipeline. Keep the direct path for single-panel queries and dimensions beyond the scratch budget, and isolate the scratch frame from that path. Name the input adapters BSource and LoadBGroup, and cover decoded groups, metadata, tile boundaries, and scratch-budget fallback. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Fold the single-use canonical BPanel constructor into BSource::panel and share full-panel and tail Visitor initialization through an always-inlined private helper. Preserve traversal, bounds checks, and computation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Keep the Visitor source field beside b_stride in both declaration and initialization. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Reuse f32 cache blocking with B-first predecoding and the existing packed/unpacked panel views. Separate grouped query layout from private MinMax4 row/group unpacking and keep regression coverage inline. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Use PACK-aware byte views and an explicit even/odd query layout. Separate MinMax4 row decoding from integer contraction and reuse diskann-wide SIMD operations. Clarify packed-query and document-view names while retaining the tiled execution pipeline. Known follow-up: investigate the Scalar performance regression observed in the native ARM64 PR workload. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Decode each packed byte once into low and high nibble output halves, allowing LLVM to vectorize the portable loop without changing the layout. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Remove the unused_imports expectation on check_le because the MinMax kernels now use this export. This fixes unfulfilled_lint_expectations in the PR merge build. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Mark Hildebrand (hildebrandmw)
left a comment
There was a problem hiding this comment.
I got through packed.rs. I think there's a little we can do to make the group access a bit semantically tighter. I'm still working my way through the rest. Is there a concern about using the same blocking strategy across all architectures?
Address review feedback on packed.rs: - packed::View/Panel track the logical column count; the physical block stride is SZ * k.next_multiple_of(PACK), matching BlockTransposed. from_block_transposed now uses ncols. - Add packed::Layout for logical <-> linear index conversion and use it in tests instead of repeating the formula. - Panel::group returns an explicit SZ x PACK Patch and bounds-checks the group index. ExtraWide::load takes the Patch and is now safe. - The MinMax contraction handles a partial final group out of line by zero-filling the B tail, leaving the full-group loop unchanged. - Revert an unrelated doc change in matrix_kernels/mod.rs. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Follow-up to the previous commit, which added a second copy of the block-transposed index arithmetic in packed.rs instead of removing duplication. - Replace block_transposed.rs's free padded_ncols/linear_index/col_offset with a pub(crate) BlockLayout that is the single source of truth. BlockTransposed and packed::View/Panel both use it; packed::Layout is removed. Add logical_index as the inverse for tests. - Add EvenOdd64Layout::position for the 64-dimension even/odd mapping. PackedQuery::set_row and the query, decoder, and driver tests use it instead of repeating the formula. - Test both helpers once against their definitions by enumerating the documented physical order. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
16x8 needs 16 accumulator registers and spills on AVX2's 16 ymm registers. 16x6 matches the f32 V3 kernel. On a Xeon Platinum 8370C release build it is 4-9% faster across 16/64 queries and 250/256 dimensions; V4 is unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Thanks! I addressed the
For V3, yes. I measured register and cache blocking on a Xeon Platinum 8370C (AVX-512) with release builds, across the multi-vector shapes from 16x16x256 to 64x1250x512. I didn't use
|
B holds nibbles, so four u8 x u4 products sum to at most 15300. The Scalar dot product can multiply in u16 and widen once when accumulating into u32. On a Xeon Platinum 8370C this makes the Scalar kernel 2.6-2.7x faster in a baseline x86-64 build and 2.1-2.2x faster with x86-64-v3. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Resolve the PACK-aware packed-view tests against the Matrix element API introduced on main. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Mark Hildebrand (hildebrandmw)
left a comment
There was a problem hiding this comment.
I have largely structural comments for this round. There is a lot of cross-talk between components. For example, EvenOddLayout64 duplicates dimensions that should already exist in a large number of other places, is AVX2 specific, and doesn't layer well with views. Lots of panic locations. The Decoder trait is hard-coupled with the EvenOdd64Layout, and ExtraWide is doing the entire micro-kernel, etc.
This feels like it's headed in the right direct, but still needs tightening of its underlying abstractions (and maybe bringing in new ones when current ones aren't doing the trick).
One big request I have is whenever there is some missing functionality in diskann-wide, please add it (or ask me to) so we can improve that library for everyone.
| #[expect( | ||
| clippy::expect_used, | ||
| reason = "canonical representation supplies a nonzero valid row stride" | ||
| )] |
There was a problem hiding this comment.
This is playing pretty fast and loose with the expects. Generally, panicking upwraps should be reserved for things that can only happen as logical errors in the internal implementation.
There are a few somewhat dangerous assumptions being made here:
doc.repr().ncols() == 0: Not guaranteed becauseMinMaxMetadoes not guarantee this in the constructor. In particular, the computation withintrinsic_dimcould overflow. This invariant should be built intoMinMaxMetadirectly.- Similarly, converting
MatRef<'_, MinMaxMeta<_>>to a slice is something thatMatRefshould handle automatically.
| // SAFETY: V3 provides AVX2. B contains unsigned nibbles, so each | ||
| // pair sum is at most 2 * 255 * 15 and cannot saturate. | ||
| let products = i16s::from_underlying(self, unsafe { | ||
| _mm256_maddubs_epi16(a.to_underlying(), b.to_underlying()) |
There was a problem hiding this comment.
By popular demand, we know have a way (see #1437) to support odd-ball instructions like this. Please add this to the V3 and V4 backends with a miri fallback. Then we won't constantly have to work around it.
|
|
||
| /// Only row-wise writes in original dimension order can populate this storage. | ||
| #[derive(Debug)] | ||
| pub(crate) struct PackedQuery<const MR: usize, const PACK: usize> { |
There was a problem hiding this comment.
This feels like a break-down in abstractions. It layers a permutation pattern on top of a data structure with a permutation pattern, making it harder to reason about the overall layout.
I think we need a better way of spinning up customizations like this. For example, kind of like PermutationStrategy but generalized. Mat was supposed to be able to do this, but the approach taken by that turned out to be pretty compromised. We need a central way of working with interleavings like this.
| } | ||
|
|
||
| impl EvenOdd64Layout { | ||
| pub(super) const BLOCK: usize = 64; |
There was a problem hiding this comment.
Is 64 a universally good number? It means we can't use AVX-512 to do the on-the-fly unpacking and makes Arm work a little harder.
| impl ExtraWide<4, 16> for V3 { | ||
| type Query = ( | ||
| <V3 as Architecture>::u8x32, | ||
| Option<<V3 as Architecture>::u8x32>, |
There was a problem hiding this comment.
Micro-kernels should be doing as regular and as minimal work as possible. Why is there an Option in the query type?
| #[cfg(not(miri))] | ||
| { | ||
| diskann_wide::alias!(u64s = <V4>::u64x8); | ||
| Self::Splat::from_underlying( |
There was a problem hiding this comment.
Please add missing diskann-wide abstractions instead of working around them. It's more durable and keeps code here much less noisy.
For example, this one could use a SIMDReinterpret<u64x8, i8x16>.
|
|
||
| // Kept separate from contraction so metadata and floating point constraints do not | ||
| // leak through ExtraWide's opaque register types. | ||
| macro_rules! compensate { |
There was a problem hiding this comment.
Surely there is a better way than a 10 argument macro...
| .checked_add(std::mem::size_of::<MinMaxCompensation>()) | ||
| .expect("document row size overflow"); | ||
| NR.checked_mul(b_bytes) | ||
| .expect("document panel size overflow"); |
There was a problem hiding this comment.
This many panics in a constructor is a sign that (A) results should be used instead (B) we're missing an abstraction somewhere else or (C) the internal invariants are too complicated.
In this situation, I think it's a heavy dose of B.
The a and b types should be able to report their number of bytes without requiring checked arithmetic, there is likely a way to package a with its metadata earlier, etc.
Reuse existing SIMD dot and reduction operations with byte reinterpretations. Move contraction and column-tail traversal out of ExtraWide, replace optional query registers with fixed arrays, and specialize half panels at kernel entry. Cover wrapping arithmetic and padded panel paths. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…1445) - [x] Does this PR have a descriptive title that could go in our release notes? - [ ] Does this PR add any new dependencies? No. - [x] Does this PR modify any existing APIs? Adds trait implementations only. - [x] Is the change to the API backwards compatible? - [ ] Should this result in any changes to our documentation? No separate documentation changes are needed. #### Reference Issues/PRs Extracted from #1394 to land the reusable diskann-wide support independently. #### What does this implement/fix? Briefly explain your changes. Adds bit-preserving SIMD reinterpretations from word lanes to byte lanes: - V3: `u32x8` → `u8x32` and `i8x32`. - V4: `u64x8` → `u8x64` and `i8x64`. - Matching little-endian `Emulated` implementations for all four conversions. Tests follow the existing conversion-test macro and architecture-check conventions. They compare raw bytes using distinct byte positions, signed-byte boundary patterns, and the existing seeded test driver. The change is limited to diskann-wide. No MaxSim consumers, custom intrinsics, or unrelated refactors are included. #### Any other comments? Local validation passed: - `cargo test -p diskann-wide` - `cargo fmt --all` - `cargo clippy --workspace --all-targets -- -D warnings` - `cargo +nightly miri test -p diskann-wide --lib reinterpret_` --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Summary
Adds prepared, tiled MaxSim kernels for MinMax8 queries against packed MinMax4 documents, with Scalar, x86-64 V3/V4, and ARM64 Neon implementations.
Query preparation packs the query vectors and their compensation metadata once. Each computation borrows canonical MinMax4 documents, decodes document tiles into a shared byte layout, and computes one MaxSim distance per query vector.
Data layout
Query storage
Queries use
BlockTransposed<u8, MR, PACK>storage.MRis the number of query rows in a panel, andPACKis the number of consecutive bytes from each query consumed by one integer dot-product step. The packed views and panels carryPACKexplicitly.EvenOdd64Layoutdefines the dimension order shared by query preparation and document decoding. Within each block of 64 dimensions, even dimensions precede odd dimensions:The original dimension
Dis rounded up to a padded dimensionK, a multiple of 64. Dimension padding and unused query rows contain zero. Zero-dimensional inputs are handled separately. MinMax compensation uses the original dimensionD.PACKcontrols physical query grouping independently of the logical dimension permutation. The contraction length counts padded scalar dimensions, withK / PACKdot-product steps.Document decoding
The decoder consumes four-bit codes without canonical headers or compensation metadata. Each complete block contains 32 packed bytes and produces 64 byte-valued codes: the 32 low nibbles followed by the 32 high nibbles.
Partial blocks are zero-filled: V4 uses a masked load, and the other backends copy into a bounded local buffer. When
Dis odd, the unused high nibble of the final packed byte is cleared. The decoder overwrites every output byte, including padding.Components and execution
Preparedlayout.rsdecode.rsreader.rsDriverPanelKernel/MicroKernelThe driver uses B-first traversal so decoded document data is reused across query tiles and panels:
Traversal uses the library's packed/unpacked views, panel visitors, remainder handling, byte-based cache-blocking parameters, and bounds-tracked pointers. Cache working-set estimates include query and document compensation metadata.
The driver receives a
PackedQueryViewandMinMax4Rows, which retain their layout and representation contracts. Integer contraction consumes byte panels and the padded dimension; compensation separately uses metadata and the original dimension. Register representations remain opaque behindExtraWide.Two per-call scratch buffers hold decoded B values and B metadata for one tile and are reused across the computation. Persistent query state remains read-only. Documents are not modified, and no full intermediate distance matrix is materialized.
Architecture configurations
maddubsandmadddpbusd, with adjacent accumulator lanes folded during reductionUDOTArchitecture-specific operations execute within their target-feature context. Logical query-row counts allow a backend to omit an unused upper half of a query panel.
API
The factory supports automatic architecture selection and explicit Scalar, V3, V4, and Neon requests. Unavailable requested architectures return
NotSupported.MaxSimIsa::Referenceis not supported by this factory.Behavior
f32::MAXfor empty documents and zero for nonempty zero-dimensional documents.Test coverage
The PR includes focused tests for: