Skip to content

[multi vector] Distance Kernels - #1368

Open
Mark Hildebrand (hildebrandmw) wants to merge 26 commits into
mainfrom
mhildebr/multivector
Open

[multi vector] Distance Kernels#1368
Mark Hildebrand (hildebrandmw) wants to merge 26 commits into
mainfrom
mhildebr/multivector

Conversation

@hildebrandmw

@hildebrandmw Mark Hildebrand (hildebrandmw) commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Infrastructure investment for multi-vector distances.

This PR replaces the implementation of f32 and f16 max-sim kernels with one that takes approximately 2.8 times the amount of code! 😢

Some things it does:

  • AVX2 f32 and f16 kernels are ~10% faster.
  • Maxsim output buffers don't need to be padded any more.
  • Native support for AVX-512 (which is potentially quite a bit faster).
  • Native support for Neon.
  • Support for multiple micro-kernel shapes.

Some things it doesn't do:

  • Add better cache sizing determination.
  • Cache blocking along the contraction dimension.
  • On the fly packing of matrices.

Why

We eventually want to support quantized multi-vector operations. The hope with this PR is that it introduces enough design ideas and infrastructure to make the process easier.

The design in the PR follows several principles:

  • From a performance standpoint, data structures representing memory spans and 2D matrix views are kept as lean as possible. This means discarding information that is expected to be known by something higher in the stack. For example, the contraction dimension K in a MxK . KxN matrix-matrix multiplication is shared by both the left-hand and right-hand arguments. Memory views thus shouldn't independently track this information because:

    • We can rely on this always being the same, cutting down on stack space and unneeded asserts.
    • Passing the dimension K externally helps LLVM with code-generation.
  • For better debugging, these dimensions above that get thrown away are retained and tracked closely. I found this helps tremendously when working on corner cases and helps document the assumptions about the intended memory use.

  • Strongly typed data structures for representing memory layouts. In release builds, these boil down to basically a pointer and one dimension. In addition, these representations allow for some sharing of infrastructure when it comes to traversing memory.

Suggested Reviewing Order

Most of this rather large diff is infrastructure. The kernels themselves are a relatively small fraction. From lower-level to higher level:

matrix_kernels

  • util.rs/num.rs: Small utilities and strongly typed integers.

  • bounds.rs: This is the main debugging aid. A Bound is an integer under debug_assertions and test builds, and a ZST otherwise. It is used in the contexts mentioned above where the true lengths and sizes of data structures should be known by another component and are hence not tracked in optimized build, but exist in debug builds to double-check indexing.

  • ptr.rs: Basically a &[T] and &mut [T] but with its length a Bound rather than a true usize. These effectively become pointers in release builds, but are full slices in debug ubilds.

  • driver.rs: Simple semantic traits.

    • Drive: The entry point for full GEMM operations. In this PR, Drive implementations involve multiple invocations of a lower-level PanelKernel.
    • PanelKernel: A step working on a sub-portion of the full operation with arguments fixed in the L1 and L2 cache. In this PR, PanelKernel implementations involve multiple invocations of a lower-level MicroKernel.
    • MicroKernel: A low level operation where two operands in memory are (hopefully) in the L1 cache. This contains the broadcasting FMAs.
  • blocks/unpacked.rs: A view over a row-major or column-major matrix. The terminology "unpacked" means that the inner dimension k for each row/column is the contiguous one in memory.

    This has a few refinements:

    • Panel: A view where the number of rows/columns (not the k dimension) is a compile time constant. This is used to parameterize micro-kernels.
    • Remainder: A view with a struct upper-bound on the number of rows/columns (not the k dimension). This is used to represent the tail portion of visitation across panels that make up a parent view.
  • blocks/packed.rs: The equivalent of the unpacked view for transposed matrices. This representation is key the performance of a GEMM operation. Typically, packing into this representation is done incrementally, but both our current and this implementation pre-pack the query into this layout.

  • maxsim/packed_f32_x_unpacked_f32.rs: The entry point for the f32 x f32 maxsim. This follows a hierarchy of Driver -> PanelKernel -> MicroKernel where each level tries to take advantage of memory locality.

  • maxsim/packed_f32_x_unpacked_f16.rs: The entry point for the f32 x f16 maxsim. This incrementally convers the RHS to f32 before entering the PanelKernel for the f32 x f32 case.

The top level drivers are currently parameterized by the blocking factors used in the micro-kernel. Blocking factors are represented as MR x NR where the left-hand A matrix is blocked by MR and the right-hand B matrix is blocked by NR. Micro-kernels compute inner products for MR rows of A with NR columns of B. There number of SIMD registers needed to hold the partial accumulators is given by

(MR / SIMD_WIDTH) * NR

So a 16x6 blocked kernel for AVX2 requires (16 / 8) * 6 = 12 accumulators (close to the architectural limit of 16 registers). A 32x6 AVX-512 kernel also requires 12 accumulators since AVX-512 registers are twice as wide.

multi_vector

The factory methods are wired up to reasonable defaults for benchmarking.

Benchmarks

Some benchmark numbers are listed below. Values are speedup relative to main.

Q D Dim V3 f32 V3 f16 V4 f32 V4 f16 Scalar f32
8 32 128 1.04x 1.10x 0.98x 1.09x 1.00x
16 64 256 1.11x 1.10x 1.01x 1.09x 1.00x
32 64 128 1.10x 1.23x 2.03x 2.25x 1.00x
32 128 384 1.12x 1.16x 1.79x 1.81x 1.00x
32 16 256 1.09x 1.51x 2.00x 2.80x 1.00x
32 32 512 1.09x 1.32x 1.70x 1.96x 1.00x
32 1250 128 1.07x 1.08x 2.05x 1.94x 0.99x
64 32 264 1.03x 1.31x 1.66x 2.12x 1.00x
64 64 128 1.07x 1.22x 2.02x 2.18x 1.00x
64 1250 512 1.13x 1.15x 1.76x 1.77x 1.00x
128 32 264 1.04x 1.33x 1.68x 2.12x 1.00x
128 64 128 1.08x 1.24x 1.97x 2.23x 1.00x
128 1250 512 1.15x 1.15x 1.77x 1.84x 1.00x

There is still plenty of work to be done with respect to performance tuning (better cache blocking, kernel selection, maybe prefetching etc). This PR intentionally keeps the same cache size heuristics as main. But there is obviously still performance on the table. For example, the Q = 32, D = 32, DIM = 512 case on my laptop gets 10.0 ns/IP where-as MKL gets 9.99. Since we have the benefit of pre-packing A and can avoid materializing C, we should be able to do better.

@partychen

juchen-ms (partychen) commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Hi Mark, I completed a broader performance investigation of PR #1368 at 8dcb7dcdd22fea859329753d3fa81d8c36e90414 against its baseline 163265176b6473fa4565eda1c47f2b8e940f0bea.

I tested the Scalar and V3 paths locally on an Intel Core Ultra 7 268V. Since that machine does not support AVX-512, I ran the V4 investigation separately on an Intel Xeon Platinum 8370C that passes DiskANN’s complete x86-64-v4 detection.

Both sets of benchmarks used the same nine shapes, three passes per configuration, and 50 measurements per pass. The primary metric was minimum ns/IP across all 150 measurements. Negative percentages mean faster execution.

Scalar and V3 results

The V3 results look encouraging:

Path PR vs baseline median P90 Range
f32 V3 -7.68% -4.24% -8.90% to -4.24%
f16 V3 -17.56% -7.02% -32.05% to -7.02%

The PR V3 implementation was faster than the baseline on all nine shapes for both element types.

However, the f32 Scalar path showed a large and consistent regression:

Path PR vs baseline median P90 Range
f32 Scalar +74.15% +87.99% +50.60% to +87.99%

Scalar investigation

I traced the Scalar regression to ExtraWide<8> for Scalar, where mul_add_splat currently uses:

core::array::from_fn(|i| a[i].mul_add_simd(b, acc[i]))

The emulated Scalar implementation invokes f32::mul_add for every lane. The baseline Scalar microkernel intentionally uses separate multiplication and addition to avoid potentially lowering this to an expensive software FMA:

core::array::from_fn(|i| a[i] * b + acc[i])

I tested only this one-line change while leaving the rest of #1368 unchanged. Scalar performance returned to approximately baseline level:

Path Median Range
Modified PR Scalar vs baseline approximately -0.08% -4.10% to +1.41%

All selected MaxSim tests still passed.

This suggests that Scalar should probably specialize this operation to use separate multiplication and addition, while V3 and V4 continue using hardware mul_add_simd. The remaining question is whether fused single-rounding behavior is an intentional requirement for the Scalar path.

Unmodified #1368: V4 versus V3

Before making any experimental V4 changes, I benchmarked the original V4 and V3 implementations from the same #1368 commit and the same release binary on the Xeon Platinum 8370C.

All V4 correctness tests executed successfully.

Type Original V4 vs PR V3 median Range
f32 +3499% +3308% to +3739%
f16 +3149% +2051% to +3350%

In other words, the unmodified V4 implementation had approximately 36× the V3 latency for f32 and 32× the V3 latency for f16.

These results come directly from the unmodified PR code, before applying any experimental microkernel or changing the MR/NR geometry. The regression was highly stable rather than thermal noise: the maximum V4 pass-to-pass spread was below 0.5%.

V4 code-generation investigation

Disassembly showed that the generic micro_kernel<W> / ExtraWide<16> for V4 path did not inline the AVX-512 intrinsics into the hot loop when diskann-quantization was compiled using the repository default target-cpu=x86-64-v3.

Each broadcast, FMA, and max operation became an out-of-line call:

call _mm512_set1_ps
call _mm512_fmadd_ps
call _mm512_max_ps

These calls spilled ZMM values through memory and also executed vzeroupper.

I tried wrapping the outer Driver::drive in a V4 target-feature boundary, but that did not solve the problem because the cross-crate SIMD trait operations remained out of line.

Experimental V4 code-generation fix

As an experiment, I added a concrete V4 microkernel inside diskann-quantization, annotated with:

#[target_feature(enable = "avx512f")]

The concrete microkernel invokes _mm512_* intrinsics directly, and the V4 MicroKernel implementations dispatch to it once per panel.

After this change, the generated hot loop contained direct ZMM loads, broadcast-FMAs, and max instructions without per-operation calls.

Compared with the original unmodified V4 implementation, this experimental fix improved performance by:

Type Fixed V4 vs original V4 Range
f32 29.38× faster 22.16×–34.49×
f16 21.88× faster 16.93×–25.31×

These speedups are relative to the original broken V4 code-generation path, not relative to V3.

All 12 MaxSim tests passed, including the V4 driver, panel, and microkernel tests.

V4 MR/NR exploration

After fixing the V4 code-generation problem, I benchmarked these tile configurations:

16×6
16×8
16×12
32×6
32×8
32×12

Each configuration used the same nine shapes and 150 measurements per shape.

MR=16

Relative to the existing 16×6 configuration:

Type Tile Median Best Worst Best among tested tiles
f32 16×8 -8.44% -13.50% +10.42% 3/9 shapes
f32 16×12 -7.53% -25.27% -2.34% 6/9 shapes
f16 16×8 -8.77% -30.57% -4.02% 3/9 shapes
f16 16×12 -12.56% -21.71% +26.24% 6/9 shapes

16×12 was the strongest general V4 tile.

For f32, it improved all nine shapes relative to 16×6 and was the fastest tested tile on six shapes.

For f16, it was also the fastest tested tile on six shapes, although one Q=16, D=64, Dim=256 result was unstable. 16×8 may be safer if worst-case consistency is more important.

MR=32

MR32 improved some small-D or low-dimensional shapes by approximately 40–50%, but it regressed sustained high-dimensional workloads by more than 2×:

Type Tile Median vs 16×6 Worst
f32 32×6 +21.82% +134.22%
f32 32×8 +10.77% +133.45%
f32 32×12 +4.92% +134.62%
f16 32×6 +2.42% +137.77%
f16 32×8 -5.27% +138.01%
f16 32×12 -5.18% +138.94%

MR32 therefore does not appear suitable as an unconditional default on Ice Lake. The sustained regressions may be related to register pressure and/or AVX-512 throughput and frequency behavior. It may only be useful through shape- or microarchitecture-dependent dispatch.

Best fixed V4 versus V3

Finally, I compared the best general V4 tile, 16×12, directly with PR V3 using the same binary and an interleaved three-pass order:

Type Fixed 16×12 V4 vs PR V3 median Range V4 wins
f32 +18.51% +0.36% to +22.90% 0/9
f16 +19.50% +4.95% to +28.38% 0/9

Therefore, the concrete microkernel removes the catastrophic code-generation regression, and 16×12 is the strongest general V4 tile tested. However, V3 remains faster on this Ice Lake 8370C across all nine shapes.

Current conclusions

  1. The new V3 implementation performs well: it improved all tested f32 and f16 shapes relative to the baseline.
  2. The Scalar regression appears to come from using per-lane f32::mul_add; using separate multiplication and addition restores baseline performance.
  3. The unmodified [multi vector] Distance Kernels #1368 V4 path is approximately 32–36× slower than PR V3 because its AVX-512 operations remain out of line.
  4. V4 needs a concrete target-feature/code-generation boundary; the current generic cross-crate path does not produce acceptable assembly under the V3 compilation target.
  5. MR=16, NR=12 is the strongest general V4 configuration tested.
  6. MR32 should not be enabled unconditionally.
  7. On Ice Lake, Auto probably should not select V4 based only on ISA availability.
  8. The V4 configurations should be benchmarked on Sapphire Rapids or newer hardware before making a global dispatch decision.

For Scalar, would it make sense to specialize mul_add_splat to use separate multiplication and addition while preserving hardware FMA for V3 and V4?

For V4, does a dedicated target-feature leaf microkernel fit the direction you had in mind, or would you prefer to address the target-feature/inlining boundary inside diskann-wide?

I have the experimental patches, disassembly, complete per-shape tables, and raw benchmark outputs available if useful.

@hildebrandmw

Copy link
Copy Markdown
Contributor Author

Thanks for the write up Junkui. The issues around scalar and AVX-512 are known and will be addressed before this PR is moved out of draft state. For all architecture, a strategic call to Architecture::run is needed. Probably at the entrance to Drive::drive. That will apply the correct target features.

Thanks for the exploration on MR vs NR on AVX-512. I'm a little surprised 32x6 performed poorly as this is the blocking used by many BLAS implementations. This PR isn't quite at the performance tuning phase though, and I suspect our cache sizing is incorrect.

@codecov-commenter

Codecov Comments Bot (codecov-commenter) commented Sep 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.77264% with 67 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.60%. Comparing base (fabcb9b) to head (dd23f36).
⚠️ Report is 5 commits behind head on main.

Files with missing lines Patch % Lines
...-quantization/src/multi_vector/distance/factory.rs 53.06% 46 Missing ⚠️
diskann-quantization/src/matrix_kernels/num.rs 86.66% 6 Missing ⚠️
...skann-quantization/src/matrix_kernels/test_util.rs 75.00% 5 Missing ⚠️
...quantization/src/multi_vector/distance/fallback.rs 33.33% 4 Missing ⚠️
diskann-quantization/src/matrix_kernels/ptr.rs 98.78% 3 Missing ⚠️
...matrix_kernels/maxsim/packed_f32_x_unpacked_f32.rs 99.53% 2 Missing ⚠️
...n-quantization/src/matrix_kernels/blocks/packed.rs 99.65% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #1368      +/-   ##
==========================================
+ Coverage   91.55%   92.60%   +1.04%     
==========================================
  Files         521      524       +3     
  Lines      100371   102090    +1719     
==========================================
+ Hits        91895    94540    +2645     
+ Misses       8476     7550     -926     
Flag Coverage Δ
miri 92.60% <96.77%> (+1.04%) ⬆️
unittests 92.54% <96.73%> (+1.30%) ⬆️

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

Files with missing lines Coverage Δ
diskann-quantization/src/lib.rs 100.00% <ø> (ø)
...quantization/src/matrix_kernels/blocks/unpacked.rs 100.00% <100.00%> (ø)
diskann-quantization/src/matrix_kernels/bounds.rs 100.00% <100.00%> (ø)
...matrix_kernels/maxsim/packed_f32_x_unpacked_f16.rs 100.00% <100.00%> (ø)
...ann-quantization/src/matrix_kernels/maxsim/test.rs 100.00% <100.00%> (ø)
diskann-quantization/src/matrix_kernels/mod.rs 100.00% <100.00%> (ø)
diskann-quantization/src/matrix_kernels/util.rs 100.00% <100.00%> (ø)
...n-quantization/src/multi_vector/distance/kernel.rs 100.00% <ø> (ø)
...-quantization/src/multi_vector/distance/max_sim.rs 100.00% <ø> (ø)
diskann-utils/src/views.rs 100.00% <100.00%> (ø)
... and 7 more

... and 90 files with indirect coverage changes

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

@hildebrandmw
Mark Hildebrand (hildebrandmw) marked this pull request as ready for review September 3, 2026 22:04
Copilot AI lite review requested due to automatic review settings September 3, 2026 22:04

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 new factory-path kernels return 0.0 (instead of f32::MAX) for the edge case of 0-dimensional queries with empty documents, diverging from the existing/reference semantics.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR is a substantial refactor of multi-vector max-sim (“maxsim”) distance computation in diskann-quantization, replacing the prior distance/kernels/* tiling + micro-kernel stack with a new matrix_kernels infrastructure that supports more architectures (AVX-512, Neon), multiple kernel shapes, and removes the need for output padding. It also adds some supporting utilities (e.g., matrix transpose) and improves error reporting for dimension mismatches.

Changes:

  • Replaced the prior multi_vector/distance/kernels/* implementation with the new matrix_kernels module and wired the maxsim factory paths to the new drivers.
  • Added explicit dimension-mismatch error reporting (MaxSimError::UnequalDim) and enforced it in both SIMD and fallback implementations.
  • Improved AArch64 Neon min/max behavior under Miri by using an emulated path.
File summaries
File Description
diskann-wide/src/arch/aarch64/f32x4_.rs Routes min/max to standard variants and uses emulation under Miri to improve testability.
diskann-utils/src/views.rs Adds transpose() for MatrixBase plus unit tests.
diskann-quantization/src/multi_vector/distance/mod.rs Removes the old kernels module from the distance module tree.
diskann-quantization/src/multi_vector/distance/max_sim.rs Adds MaxSimError::UnequalDim for dimension mismatch reporting.
diskann-quantization/src/multi_vector/distance/kernels/tiled_reduce.rs Deletes the old generic tiling loop implementation (replaced by matrix_kernels).
diskann-quantization/src/multi_vector/distance/kernels/reduce.rs Deletes the old accumulator reduction helper trait (replaced by matrix_kernels::util::Fold).
diskann-quantization/src/multi_vector/distance/kernels/mod.rs Deletes the old kernels module root.
diskann-quantization/src/multi_vector/distance/kernels/layouts.rs Deletes the old layout + conversion traits (replaced by matrix_kernels::blocks/* + utilities).
diskann-quantization/src/multi_vector/distance/kernels/f32/v3.rs Deletes the old AVX2+FMA f32 micro-kernel implementation.
diskann-quantization/src/multi_vector/distance/kernels/f32/scalar.rs Deletes the old scalar/emulated f32 micro-kernel implementation.
diskann-quantization/src/multi_vector/distance/kernels/f32/mod.rs Deletes the old f32 kernel family entry point.
diskann-quantization/src/multi_vector/distance/kernels/f16.rs Deletes the old f16 adapter entry point.
diskann-quantization/src/multi_vector/distance/kernel.rs Updates trait docs to include the new UnequalDim error condition.
diskann-quantization/src/multi_vector/distance/fallback.rs Enforces dimension equality and returns UnequalDim for fallback computation.
diskann-quantization/src/multi_vector/distance/factory.rs Rewires the factory to use matrix_kernels maxsim drivers and updates query preparation logic.
diskann-quantization/src/matrix_kernels/mod.rs Introduces the new “GEMM-lite” kernel framework and cache model.
diskann-quantization/src/matrix_kernels/driver.rs Adds semantic driver/panel/micro-kernel traits used by the new kernel stack.
diskann-quantization/src/matrix_kernels/bounds.rs Adds debug-only bounds tracking to support safer low-level pointer math in kernels.
diskann-quantization/src/matrix_kernels/num.rs Adds strongly typed dimensions/offset units (DimK, Elements, Bytes).
diskann-quantization/src/matrix_kernels/ptr.rs Adds slice wrappers with debug-only length tracking for kernel pointer traversal.
diskann-quantization/src/matrix_kernels/util.rs Adds conversion, load/store helpers, and compile-time fold utilities used by kernels.
diskann-quantization/src/matrix_kernels/test_util.rs Adds panic-capture helpers and test distributions for matrix-kernel tests.
diskann-quantization/src/matrix_kernels/blocks/mod.rs Adds packed/unpacked block-view module root for kernel argument views.
diskann-quantization/src/matrix_kernels/blocks/packed.rs Adds packed (block-transposed) views/panels over query-like data.
diskann-quantization/src/matrix_kernels/blocks/unpacked.rs Adds unpacked views/panels over row-major/column-major-like banded data.
diskann-quantization/src/matrix_kernels/maxsim/mod.rs Adds maxsim kernel module root for the new framework.
diskann-quantization/src/matrix_kernels/maxsim/test.rs Adds shared test-case generation for maxsim reference comparisons.
diskann-quantization/src/matrix_kernels/maxsim/packed_f32_x_unpacked_f32.rs Adds the new packed-f32 × unpacked-f32 maxsim kernel stack (drivers + kernels + tests).
diskann-quantization/src/matrix_kernels/maxsim/packed_f32_x_unpacked_f16.rs Adds the new packed-f32 × unpacked-f16 driver with per-tile f16→f32 conversion and tests.
diskann-quantization/src/lib.rs Registers the new matrix_kernels module in the crate.
Review details

Suppressed comments (1)

diskann-quantization/src/multi_vector/distance/factory.rs:134

  • Same edge case as the f32 path: for 0-dimensional vectors, this fills scores with 0.0 even when doc.num_vectors() == 0, but the reference semantics for empty docs are f32::MAX.
        let Some(k) = NonZeroUsize::new(self.prepared.ncols()).map(mk::DimK::new) else {
            scores.fill(0.0);
            return Ok(());
        };
  • Files reviewed: 30/30 changed files
  • Comments generated: 3
  • Review effort level: Lite

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

Comment on lines +68 to +71
let Some(k) = NonZeroUsize::new(self.prepared.ncols()).map(mk::DimK::new) else {
scores.fill(0.0);
return Ok(());
};

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.

Saving this for a later cleanup pass.

Comment on lines +66 to +72
bounds::check_eq!(a.k(), k, "constraction dimensions to not agree");
bounds::check_eq!(b.k(), k, "constraction dimensions to not agree");
bounds::check_eq!(
bounds::Bound::new(a.blocks().get()),
c.len().div_ceil(MR),
"output length must occupiy exactly the packed A blocks",
);
Comment on lines +110 to +116
bounds::check_eq!(a.k(), k, "constraction dimensions to not agree");
bounds::check_eq!(b.k(), k, "constraction dimensions to not agree");
bounds::check_eq!(
bounds::Bound::new(a.blocks().get()),
c.len().div_ceil(MR),
"output length must occupiy exactly the packed A blocks",
);
{
/// Load up to the first `src.len()` and return the results in an array.
///
/// The remaining items items should be left in a default state.

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.

typo: “items” is repeated twice.

Comment on lines +127 to +225
|| {
// Pre-fill `c`.
self.c.fill(f32::NEG_INFINITY);

// We allow `c` to be slightly under-filled.
//
// These variables track if under-fill is happening.
let remainder = self.c.len() % MR;
let last_a_block = self.a.blocks().get() - 1;

let mut c = MutSlice::new(self.c);

let on_a_panels = |a_panels: packed::View<'_, f32, MR>, a_block_base| {
let on_b_panels = |b_panels: unpacked::View<'_, f16>, _| {
// Convert `f16` to `f32`.
//
// SAFETY: Class invariant - `self.b.k()` is equal to `self.k`.
let b_flat = unsafe { b_panels.as_std_slice(self.k) };
let b_converted = &mut self.b_converted[..b_flat.len()];
Converter::new(self.arch).convert(b_converted, b_flat);

// SAFETY: `b_converted` has length `b_panels.extent() * self.k`.
let b_panels_converted = unsafe {
unpacked::View::new(Slice::new(b_converted), b_panels.extent(), self.k)
};

let panel_kernel = |a_panel: packed::Panel<'_, f32, MR>, a_block_offset| {
// If we are in the very last block and we need to sub-fill, do that.
// Otherwise, reference the output in place.
let a_block = a_block_base + a_block_offset;
let handling_tail = a_block == last_a_block && remainder != 0;

let bound =
bounds::Bound::from_fn(
|| if handling_tail { remainder } else { MR },
);

// SAFETY: By class invariant,
//
// `MR * (self.a.blocks() - 1) < c.len() <= MR * self.a.blocks()`.
//
// From the visitor, `a_block <= self.a.blocks()`.
let mut region = unsafe { c.subslice(MR * a_block, bound) };
let c = if handling_tail {
util::LoadStore::<f32, MR>::load(
self.arch,
// SAFETY: `region` as length exactly `remainder`.
unsafe { region.as_std_slice(remainder) },
)
} else {
// SAFETY: `region` has length exactly `MR`.
unsafe { *region.as_array::<MR>() }
};

// Run the kernel
//
// SAFETY: By class invariant, `a_panel.k()` and
// `b_panels_converted.k()` are equal to `self.k`.
let mut kernel = unsafe {
PanelKernel::new(self.arch, a_panel, b_panels_converted, c, self.k)
};

driver::PanelKernel::panel_kernel(&mut kernel);

let c_final = kernel.take();

// Put back `C`.
if handling_tail {
util::LoadStore::<f32, MR>::store(
self.arch,
c_final,
// SAFETY: `region` has length exactly `remainder`.
unsafe { region.as_std_mut_slice(remainder) },
);
} else {
// SAFETY: `region` has length exactly `MR`.
unsafe { *region.as_array::<MR>() = c_final };
}
};

// SAFETY: By class invariant, `a_panels.k() == self.k`.
unsafe {
a_panels.visit_panels(self.k, panel_kernel);
}
};

// SAFETY: By class invariant, `self.b.k() == self.k`.
unsafe {
self.b
.visit_sub_views(self.params.b_cols_in_l1, self.k, on_b_panels);
}
};

// SAFETY: By class invariant, `self.a.k() == self.k`.
unsafe {
self.a
.visit_sub_views(self.params.a_panels_in_l2, self.k, on_a_panels)
};
},

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.

The A tile loop is outermost, so each A tile walks all of B. With small queries there is only ever one A tile, so this makes no difference today.

But if the query grows past one A tile, B gets re-walked, and in the f16 case re-converted, once per A tile. Is B on the outside worth exploring there?

Not claiming it is a win. Flipping it means A gets re-read instead, you would want to flip the tile sizing too, and the converted buffer stops being small scratch.

Mainly I want to understand what factors should decide this loop order. I can see a few candidates, like the relative sizes of the two operands, which one is cheaper to re-read given f16 halves B, and how the cache budget gets split between them. But I cannot tell which of these actually dominates, so any pointers would help.

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.

This loop order was chosen to get us to parity with the current code. I suspect that a less-than-optimal tiling is just one of its many flaws. It is meant to work as a baseline for exploring other orderings. We are woefully lacking infrastructure to properly sweep and benchmark different orderings.

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.

Small thing, a few typos that show up in panic messages users would see:

  1. "constraction" should be "contraction", and "to not agree" should be "do not
    agree". 8 places: f32 lines: 110, 111, 143, 144 and f16 lines: 66, 67, 97, 98.

  2. "occupiy" should be "occupy". 4 places: f32 lines: 115, 148 and f16 lines: 71, 102.

  3. A few in doc comments too: "oclumns" in matrix_kernels/mod.rs:26, "matrics" in
    num.rs:117, and "the pointer offset if valid" should be "is valid" in
    blocks/packed.rs:155 and 194 and blocks/unpacked.rs:185.

Comment on lines 558 to 569
/// Shapes for the `chamfer_matches_fallback` / `max_sim_matches_fallback`
/// agreement checks: `(num_queries, num_docs, dim)`.
///
/// Targets the factory wiring (query setup, score writeback) above the
/// kernel layer; exhaustive panel/remainder coverage is pinned in
/// `kernels::tiled_reduce::tests`.
const TEST_CASES: &[(usize, usize, usize)] = &[
(1, 1, 4), // Degenerate
(5, 3, 5), // Prime k; nq > 1 and nd > 1 exercise per-row writeback
(17, 4, 64), // A-panel remainder crossing both Scalar and V3 panel widths
(16, 6, 32), // B-remainder ≠ 1 (V3 b_remainder = 2)
];

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.

nit: The zero-doc and zero-query cases are covered well below, and I like that those loop over both Auto and Reference.

The one I could not find anywhere is dim == 0. This list starts at (1,1,4), and
the kernel-level dims in maxsim/test.rs start at [1,1,1,1,1], so nothing
exercises a zero contraction dimension. That is the case this commit changed
from f32::MAX to 0.0.

}

/// Transpose the elements in `self`.
pub fn transpose(&self) -> Matrix<T::Elem>

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.

Is this meant to be public API? Every caller I can find is inside a #[cfg(test)]
block, both the ones here and the ones in the maxsim kernel tests.

@@ -199,9 +263,12 @@
{
fn run(self, arch: V4, query: MatRef<'_, Standard<f32>>) -> E::Output {
// V4 dispatches to V3 (no V4-specific kernel).

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.

stale

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.

5 participants