Files
faiss/tests/test_distances_dispatch.cpp
T
marcelo-cjl 256980cb6e Add SVE low-dimensional nearest fast path and spherical SuperKMeans support (#5530)
Summary:
This PR adds two related changes for the SCANN coarse quantizer / PQ encoding path on ARM SVE: a low-dimensional nearest-neighbor fast path and spherical SuperKMeans support.

### 1. Low-dimensional L2sqr nearest fast path (d in {2,4,8})

`fvec_L2sqr_ny_nearest<ARM_SVE>` previously wrote all `ny` distances to the scratch buffer and then performed a separate linear scan. For PQ encoding (`compute_1_code`) each call has `d = dsub` and `ny = ksub`, making this the hot path. The new path maps one centroid to each SVE lane, gathers its `D` components, and keeps lane-local minimum distances and ids across batches. A predicated tail handles non-multiple `ny` values, equal distances preserve the first index, and only one final horizontal reduction is needed. The scratch buffer is not written, matching the x86 AVX2/AVX512 `D2/D4/D8` implementations.

`ProductQuantizer::compute_code` already uses the A1 dispatch on current `main`, so the ARM_SVE specialization remains reachable from PQ encoding after this rebase.

### 2. SuperKMeans spherical (inner-product) support

SuperKMeans previously only supported L2 clustering; the header explicitly excluded IP/cosine. With `cp.spherical=true`:
- `TrainState::R` becomes `std::unique_ptr<VectorTransform>`; power-of-two `d` uses the fast `HadamardRotation`, L2 keeps `RandomRotationMatrix`.
- Forgy initialization produces unit centroids before the first assignment. Each iteration computes and splits centroids first, then applies spherical centroid post-processing once, matching the `Clustering` flow.
- `HadamardRotation::reverse_transform` fills the missing inverse.
- `ClusteringParameters::use_super_kmeans` (default false) lets `Level1Quantizer::train_q1` route coarse quantizer training through SuperKMeans when explicitly enabled; the Python type stubs expose the new option.
- `block_l2<ARM_SVE>` completes the SuperKMeans SIMD kernels for the ADSampling pruning loop.

## Tests

- Low-dim nearest across SIMD levels: `d` in `{2,4,8}` and `ny` around common SIMD widths through 257, including non-multiple tails and first-index tie behavior. The dispatch test source is included in DD builds.
- Spherical objective close to vanilla spherical Clustering; unit-norm centroids.
- `use_super_kmeans` field inherited from `ClusteringParameters`.

## Benchmark

qwen 4096-dim, IP, nlist=1024, sub_dim=4, 1 thread, 50k rows:

| stage | baseline | optimized | change |
| --- | ---: | ---: | --- |
| Train | 67.73s | 24.36s | -64% |
| Add | 10.86s | 7.17s | -34% |
| Build | 78.59s | 31.52s | -60% |

Train speedup comes from SuperKMeans spherical coarse quantizer training; Add speedup comes from the low-dim SVE nearest fast path in PQ encoding. recall@10 unchanged vs Clustering baseline (diff <= 0.0002).

## Notes

This change targets ARM SVE. The x86 (AVX2/AVX512) implementations already have equivalent low-dim nearest specializations. The generic ARM SVE distance path comes from current `main`; this PR adds the low-dimensional nearest specializations on top of it.

Pull Request resolved: https://github.com/facebookresearch/faiss/pull/5530

Reviewed By: mnorris11

Differential Revision: D117563661

Pulled By: alibeklfc

fbshipit-source-id: 47ab1064d781a45d009923b4d5e62525718e83bf
2026-09-02 07:56:05 -07:00

368 lines
12 KiB
C++

/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
// Per-SIMD-level cross-equivalence tests for the templated distance
// functions in faiss/utils/distances.h:
// fvec_L1, fvec_Linf, fvec_L2sqr_batch_4, fvec_inner_product_batch_4,
// fvec_L2sqr_ny_transposed, fvec_L2sqr_ny_nearest.
//
// IndexFlat exercises these transitively via search, but transitive
// coverage can mask tail-handling bugs. This test runs each function at
// every SIMDConfig-available level and asserts equivalence against the
// SIMDLevel::NONE reference. Sweeps dimensions {1, 3, 7, 8, 9, 15, 16,
// 17, 31, 32, 33, 64} so non-multiple-of-8 / non-multiple-of-16 dims
// exercise the tail paths.
//
// Tolerances:
// * Functions returning an integer (fvec_L2sqr_ny_nearest's chosen
// index) are compared bit-exactly via EXPECT_EQ.
// * Functions returning a single float (fvec_L1, fvec_Linf) accumulate
// over d terms; rounding-order differences across SIMD widths can
// reach a few ULPs. Comparison uses an absolute tolerance scaled by
// d to bound worst-case FP-reassociation drift.
// * Functions returning multiple floats (the batch_4 variants and
// fvec_L2sqr_ny_transposed) use the same scaled tolerance per element.
//
// NaN/Inf inputs are out of scope: these paths are not exercised by
// random uniform input in [-1, 1].
#include <gtest/gtest.h>
#include <algorithm>
#include <cmath>
#include <random>
#include <vector>
#include <faiss/utils/distances.h>
#include <faiss/utils/simd_levels.h>
using namespace faiss;
namespace {
class SIMDLevelGuard {
public:
SIMDLevelGuard() : prev_(SIMDConfig::get_level()) {}
~SIMDLevelGuard() {
SIMDConfig::set_level(prev_);
}
private:
SIMDLevel prev_;
};
std::vector<SIMDLevel> available_levels() {
static const std::vector<SIMDLevel> all = {
SIMDLevel::NONE,
SIMDLevel::AVX2,
SIMDLevel::AVX512,
SIMDLevel::AVX512_SPR,
SIMDLevel::ARM_NEON,
SIMDLevel::ARM_SVE,
};
std::vector<SIMDLevel> out;
for (auto lv : all) {
if (SIMDConfig::is_simd_level_available(lv)) {
out.push_back(lv);
}
}
return out;
}
std::vector<float> rand_vec(size_t n, uint32_t seed) {
std::mt19937 rng(seed);
std::uniform_real_distribution<float> u(-1.f, 1.f);
std::vector<float> v(n);
for (auto& x : v) {
x = u(rng);
}
return v;
}
// dimensions covering aligned (multiple of 8 / 16) and tail cases.
const std::vector<size_t> kDims = {1, 3, 7, 8, 9, 15, 16, 17, 31, 32, 33, 64};
// Tolerance per accumulator term. With inputs in [-1, 1], each squared term
// is in [0, 4]; FP-reassociation differences across SIMD widths grow at
// most linearly with d. 1e-6 per term gives a generous bound (e.g. 6.4e-5
// at d=64) that is still many orders of magnitude tighter than any real
// dispatch bug would produce.
constexpr float kPerTermEps = 1e-6f;
float tol_for_d(size_t d) {
// Floor at 1e-6 so d=1 still has a sensible (non-zero) tolerance.
return std::max(static_cast<float>(d) * kPerTermEps, 1e-6f);
}
#define SKIP_IF_SINGLE_LEVEL(levels) \
if ((levels).size() <= 1) { \
GTEST_SKIP() << "only one SIMD level available; nothing to compare"; \
}
template <typename Fn>
void check_floats_at_levels(
Fn fn,
const std::vector<SIMDLevel>& levels,
float tol) {
auto ref = (SIMDConfig::set_level(SIMDLevel::NONE), fn());
for (auto lv : levels) {
if (lv == SIMDLevel::NONE) {
continue;
}
SIMDConfig::set_level(lv);
auto got = fn();
ASSERT_EQ(ref.size(), got.size())
<< "size mismatch at level " << static_cast<int>(lv);
for (size_t i = 0; i < ref.size(); ++i) {
EXPECT_NEAR(ref[i], got[i], tol)
<< "diverged at level " << static_cast<int>(lv) << " idx "
<< i;
}
}
}
// For functions returning an integer index (cast to size_t). Bit-exact
// comparison: a correct dispatch must return the same chosen index.
template <typename Fn>
void check_index_at_levels(Fn fn, const std::vector<SIMDLevel>& levels) {
auto ref = (SIMDConfig::set_level(SIMDLevel::NONE), fn());
for (auto lv : levels) {
if (lv == SIMDLevel::NONE) {
continue;
}
SIMDConfig::set_level(lv);
auto got = fn();
EXPECT_EQ(ref, got)
<< "index diverged at level " << static_cast<int>(lv);
}
}
} // namespace
TEST(DistancesDispatch, FvecL1_AllLevels) {
SIMDLevelGuard guard;
auto levels = available_levels();
SKIP_IF_SINGLE_LEVEL(levels);
for (size_t d : kDims) {
auto x = rand_vec(d, 1);
auto y = rand_vec(d, 2);
check_floats_at_levels(
[&]() {
return std::vector<float>{fvec_L1(x.data(), y.data(), d)};
},
levels,
tol_for_d(d));
}
}
TEST(DistancesDispatch, FvecLinf_AllLevels) {
SIMDLevelGuard guard;
auto levels = available_levels();
SKIP_IF_SINGLE_LEVEL(levels);
for (size_t d : kDims) {
auto x = rand_vec(d, 3);
auto y = rand_vec(d, 4);
// Linf is a max, not a sum — no FP-reassociation drift; bit-exact.
check_floats_at_levels(
[&]() {
return std::vector<float>{fvec_Linf(x.data(), y.data(), d)};
},
levels,
0.0f);
}
}
TEST(DistancesDispatch, FvecL2sqrBatch4_AllLevels) {
SIMDLevelGuard guard;
auto levels = available_levels();
SKIP_IF_SINGLE_LEVEL(levels);
for (size_t d : kDims) {
auto x = rand_vec(d, 10);
auto y0 = rand_vec(d, 11);
auto y1 = rand_vec(d, 12);
auto y2 = rand_vec(d, 13);
auto y3 = rand_vec(d, 14);
check_floats_at_levels(
[&]() {
float d0, d1, d2, d3;
fvec_L2sqr_batch_4(
x.data(),
y0.data(),
y1.data(),
y2.data(),
y3.data(),
d,
d0,
d1,
d2,
d3);
return std::vector<float>{d0, d1, d2, d3};
},
levels,
tol_for_d(d));
}
}
TEST(DistancesDispatch, FvecInnerProductBatch4_AllLevels) {
SIMDLevelGuard guard;
auto levels = available_levels();
SKIP_IF_SINGLE_LEVEL(levels);
for (size_t d : kDims) {
auto x = rand_vec(d, 20);
auto y0 = rand_vec(d, 21);
auto y1 = rand_vec(d, 22);
auto y2 = rand_vec(d, 23);
auto y3 = rand_vec(d, 24);
check_floats_at_levels(
[&]() {
float d0, d1, d2, d3;
fvec_inner_product_batch_4(
x.data(),
y0.data(),
y1.data(),
y2.data(),
y3.data(),
d,
d0,
d1,
d2,
d3);
return std::vector<float>{d0, d1, d2, d3};
},
levels,
tol_for_d(d));
}
}
TEST(DistancesDispatch, FvecL2sqrNyTransposed_AllLevels) {
SIMDLevelGuard guard;
auto levels = available_levels();
SKIP_IF_SINGLE_LEVEL(levels);
constexpr size_t ny = 13;
for (size_t d : kDims) {
// d_offset is the stride between consecutive components in the
// transposed y layout; pick > ny to exercise non-trivial striding
// and to cover all (i, k) accesses (i in [0, ny), k in [0, d)).
const size_t d_offset = ny + 5;
auto x = rand_vec(d, 30);
// y[i + k * d_offset] is the k-th component of the i-th vector
// (per fvec_L2sqr_ny_transposed's contract). Required size is
// (d - 1) * d_offset + ny.
auto y = rand_vec((d == 0 ? 0 : (d - 1) * d_offset) + ny, 31);
std::vector<float> y_sqlen(ny);
for (size_t i = 0; i < ny; ++i) {
float s = 0;
for (size_t k = 0; k < d; ++k) {
float v = y[i + k * d_offset];
s += v * v;
}
y_sqlen[i] = s;
}
check_floats_at_levels(
[&]() {
std::vector<float> dis(ny);
fvec_L2sqr_ny_transposed(
dis.data(),
x.data(),
y.data(),
y_sqlen.data(),
d,
d_offset,
ny);
return dis;
},
levels,
tol_for_d(d));
}
}
TEST(DistancesDispatch, FvecL2sqrNyNearest_AllLevels) {
SIMDLevelGuard guard;
auto levels = available_levels();
SKIP_IF_SINGLE_LEVEL(levels);
constexpr size_t ny = 23;
for (size_t d : kDims) {
auto x = rand_vec(d, 40);
auto y = rand_vec(d * ny, 41);
check_index_at_levels(
[&]() {
// distances_tmp_buffer is an internal scratch; only the
// returned index is part of the public contract. Some
// SIMD impls don't write into the buffer.
std::vector<float> tmp(ny);
return fvec_L2sqr_ny_nearest(
tmp.data(), x.data(), y.data(), d, ny);
},
levels);
}
}
// The low-dimensional nearest path (d in {2,4,8}, one SIMD lane per
// centroid) must agree with the generic implementation for any ny, including
// values around common SIMD widths and non-multiple tails.
TEST(DistancesDispatch, FvecL2sqrNyNearest_LowDim) {
SIMDLevelGuard guard;
auto levels = available_levels();
SKIP_IF_SINGLE_LEVEL(levels);
constexpr size_t kLowDims[] = {2, 4, 8};
constexpr size_t kNy[] = {
1,
2,
3,
7,
8,
9,
15,
16,
17,
23,
31,
32,
33,
63,
64,
65,
255,
256,
257};
for (size_t d : kLowDims) {
for (size_t ny : kNy) {
auto x = rand_vec(d, 40);
auto y = rand_vec(d * ny, 41);
check_index_at_levels(
[&]() {
std::vector<float> tmp(ny);
return fvec_L2sqr_ny_nearest(
tmp.data(), x.data(), y.data(), d, ny);
},
levels);
}
}
}
TEST(DistancesDispatch, FvecL2sqrNyNearest_LowDimKeepsFirstTie) {
SIMDLevelGuard guard;
auto levels = available_levels();
SKIP_IF_SINGLE_LEVEL(levels);
constexpr size_t kLowDims[] = {2, 4, 8};
constexpr size_t ny = 23;
constexpr size_t expected = 3;
for (size_t d : kLowDims) {
std::vector<float> x(d, 0.0f);
std::vector<float> y(d * ny, 1.0f);
std::fill_n(y.data() + expected * d, d, 0.0f);
std::fill_n(y.data() + 17 * d, d, 0.0f);
auto nearest = [&]() {
std::vector<float> tmp(ny);
return fvec_L2sqr_ny_nearest(tmp.data(), x.data(), y.data(), d, ny);
};
SIMDConfig::set_level(SIMDLevel::NONE);
EXPECT_EQ(expected, nearest());
check_index_at_levels(nearest, levels);
}
}