Skip to content

Commit 758a455

Browse files
committed
SVE low-dim nearest fast path + SuperKMeans spherical support
Summary: Two related optimizations for the SCANN coarse quantizer / PQ encoding path, both targeting ARM SVE. 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 then did 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 keeps one SVE lane per centroid via D svld1 loads and tracks the min index entirely in registers; the scratch buffer is not written, matching the x86 AVX2/AVX512 fvec_L2sqr_ny_nearest_D2/D4/D8 implementations. ProductQuantizer::compute_code switches to AVAILABLE_SIMD_LEVELS_A1 so the ARM_SVE implementation is reachable from PQ encoding (A0 does not include ARM_SVE). 2. SuperKMeans spherical (inner-product) support SuperKMeans previously only supported L2. With cp.spherical=true: - TrainState::R becomes std::unique_ptr<VectorTransform>; power-of-two d uses the fast HadamardRotation, L2 keeps RandomRotationMatrix. - update_centroids_and_split and Forgy init renormalize centroids to unit length, so minimizing L2 is equivalent to maximizing inner product. - 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. - block_l2<ARM_SVE> completes the SuperKMeans SIMD kernels. Tests: low-dim nearest across SIMD levels (d in {2,4,8}, varied ny), spherical objective vs vanilla spherical Clustering, unit-norm centroids, use_super_kmeans field inheritance. Python stubs updated. Measured on qwen 4096-dim, IP, nlist=1024, sub_dim=4, 1 thread, 50k rows: - SCANN_DVR Train 67.73s -> 24.36s, Add 10.86s -> 7.17s, Build 78.59s -> 31.52s (-60%) - recall@10 unchanged vs Clustering baseline (diff <= 0.0002)
1 parent 920a631 commit 758a455

15 files changed

Lines changed: 355 additions & 21 deletions

faiss/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ set(FAISS_SIMD_NEON_SRC
6464
set(FAISS_SIMD_SVE_SRC
6565
impl/pq_code_distance/pq_code_distance-sve.cpp
6666
utils/simd_impl/distances_arm_sve.cpp
67+
utils/simd_impl/super_kmeans_kernels_sve.cpp
6768
)
6869
set(FAISS_SIMD_RVV_SRC
6970
impl/fast_scan/impl-riscv.cpp

faiss/Clustering.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,11 @@ struct ClusteringParameters {
7575
/// so the training process stops only if an error
7676
/// is unchanged from the previous iteration.
7777
double early_stop_threshold = 0.0;
78+
79+
/// Whether to use the SuperKMeans (super fast k-means) variant instead of
80+
/// the vanilla Clustering implementation. Only honored by callers that
81+
/// explicitly support it (e.g. IVF level-1 quantizer training).
82+
bool use_super_kmeans = false;
7883
};
7984

8085
struct ClusteringIterationStats {

faiss/IndexIVF.cpp

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*/
77

88
#include <faiss/IndexIVF.h>
9+
#include <faiss/SuperKMeans.h>
910

1011
#include <omp.h>
1112
#include <atomic>
@@ -78,13 +79,22 @@ void Level1Quantizer::train_q1(
7879
printf("Training level-1 quantizer on %zd vectors in %zdD\n", n, d);
7980
}
8081

81-
Clustering clus(static_cast<int>(d), static_cast<int>(nlist), cp);
8282
quantizer->reset();
83-
if (clustering_index) {
84-
clus.train(n, x, *clustering_index);
83+
if (cp.use_super_kmeans && clustering_index == nullptr) {
84+
SuperKMeansParameters super_cp;
85+
static_cast<ClusteringParameters&>(super_cp) = cp;
86+
SuperKMeans clus(
87+
static_cast<int>(d), static_cast<int>(nlist), super_cp);
88+
clus.train(n, x);
8589
quantizer->add(nlist, clus.centroids.data());
8690
} else {
87-
clus.train(n, x, *quantizer);
91+
Clustering clus(static_cast<int>(d), static_cast<int>(nlist), cp);
92+
if (clustering_index) {
93+
clus.train(n, x, *clustering_index);
94+
quantizer->add(nlist, clus.centroids.data());
95+
} else {
96+
clus.train(n, x, *quantizer);
97+
}
8898
}
8999
quantizer->is_trained = true;
90100
} else if (quantizer_trains_alone == 2) {

faiss/SuperKMeans.cpp

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,9 @@ namespace faiss {
5757
namespace {
5858

5959
struct TrainState {
60-
/// Orthogonal rotation. Train in rotated space (X_tilde = X * R);
60+
/// Fast orthogonal rotation. Train in rotated space;
6161
/// un-rotate centroids before return.
62-
faiss::RandomRotationMatrix R;
62+
std::unique_ptr<faiss::VectorTransform> R;
6363

6464
std::vector<float> X_tilde; // (n, d) row-major
6565
int n = 0;
@@ -77,7 +77,17 @@ struct TrainState {
7777
int low_pruning_streak = 0;
7878
bool low_pruning_warning_printed = false;
7979

80-
explicit TrainState(int d) : R(d, d) {}
80+
explicit TrainState(int d, bool spherical)
81+
: R([d, spherical]() -> std::unique_ptr<faiss::VectorTransform> {
82+
// Spherical (inner-product) clustering only: a power-of-two
83+
// dimension can use the fast Hadamard rotation instead of
84+
// the generic random rotation. L2 training keeps the
85+
// original RandomRotationMatrix path unchanged.
86+
if (spherical && d > 0 && (d & (d - 1)) == 0) {
87+
return std::make_unique<faiss::HadamardRotation>(d);
88+
}
89+
return std::make_unique<faiss::RandomRotationMatrix>(d, d);
90+
}()) {}
8191
};
8292

8393
/// PDX block layout for the trailing pruning sweep: block b covers original
@@ -198,7 +208,8 @@ int update_centroids_and_split(
198208
int k,
199209
TrainState& state,
200210
std::vector<int64_t>& labels64,
201-
std::vector<float>& hassign) {
211+
std::vector<float>& hassign,
212+
bool spherical) {
202213
std::fill(hassign.begin(), hassign.end(), 0.0f);
203214
assert(!labels64.empty());
204215
assert(!state.assignments.empty());
@@ -216,16 +227,23 @@ int update_centroids_and_split(
216227
/*weights=*/nullptr,
217228
hassign.data(),
218229
state.Y_tilde.data());
230+
if (spherical) {
231+
fvec_renorm_L2(d, k, state.Y_tilde.data());
232+
}
219233
if (state.n <= k) {
220234
return 0;
221235
}
222-
return detail::split_clusters(
236+
const int nsplit = detail::split_clusters(
223237
d,
224238
k,
225239
state.n,
226240
/*k_frozen=*/0,
227241
hassign.data(),
228242
state.Y_tilde.data());
243+
if (spherical) {
244+
fvec_renorm_L2(d, k, state.Y_tilde.data());
245+
}
246+
return nsplit;
229247
}
230248

231249
/// Stay-in-band controller: nudge state.d_prime based on observed pruning
@@ -295,10 +313,17 @@ std::unique_ptr<uint8_t[]> setup_train_state(
295313
"SuperKMeans: training set size exceeds INT_MAX after sampling");
296314
state.n = static_cast<int>(nx);
297315

298-
state.R.init(cp.seed);
316+
if (auto* R = dynamic_cast<HadamardRotation*>(state.R.get())) {
317+
R->init(cp.seed);
318+
} else {
319+
auto* dense_rotation =
320+
dynamic_cast<RandomRotationMatrix*>(state.R.get());
321+
FAISS_ASSERT(dense_rotation != nullptr);
322+
dense_rotation->init(cp.seed);
323+
}
299324

300325
state.X_tilde.resize(static_cast<size_t>(state.n) * d);
301-
state.R.apply_noalloc(state.n, x_sampled, state.X_tilde.data());
326+
state.R->apply_noalloc(state.n, x_sampled, state.X_tilde.data());
302327

303328
// Forgy init: pick k random rows from the rotated pool as initial
304329
// centroids. These remain in rotated space; un-rotation happens
@@ -313,6 +338,9 @@ std::unique_ptr<uint8_t[]> setup_train_state(
313338
state.X_tilde.data() + static_cast<size_t>(perm[j]) * d,
314339
sizeof(float) * d);
315340
}
341+
if (cp.spherical) {
342+
fvec_renorm_L2(d, k, state.Y_tilde.data());
343+
}
316344
}
317345

318346
state.d_prime =
@@ -339,7 +367,7 @@ std::unique_ptr<uint8_t[]> setup_train_state(
339367
/// reverse_transform applies R^T = R^-1.
340368
void untransform_centroids(
341369
std::vector<float>& centroids,
342-
const RandomRotationMatrix& R,
370+
const VectorTransform& R,
343371
int d,
344372
int k,
345373
const float* Y_tilde) {
@@ -411,7 +439,7 @@ void SuperKMeans::train(idx_t n, const float* x) {
411439
static_cast<idx_t>(k) * cp.min_points_per_centroid);
412440
}
413441

414-
TrainState state(d);
442+
TrainState state(d, cp.spherical);
415443
std::vector<int64_t> labels64;
416444
SuperKMeansAssignScratch assign_scratch;
417445
std::vector<float> hassign;
@@ -444,8 +472,8 @@ void SuperKMeans::train(idx_t n, const float* x) {
444472
pruned_at_gemm);
445473
}
446474

447-
const int nsplit =
448-
update_centroids_and_split(d, k, state, labels64, hassign);
475+
const int nsplit = update_centroids_and_split(
476+
d, k, state, labels64, hassign, cp.spherical);
449477
const float pruning_rate = (iter == 0)
450478
? 0.0f
451479
: adapt_d_prime(d, cp, state, total_pairs, pruned_at_gemm);
@@ -492,7 +520,7 @@ void SuperKMeans::train(idx_t n, const float* x) {
492520
(getmillisecs() - t_train_start) / 1000.0);
493521
}
494522

495-
untransform_centroids(centroids, state.R, d, k, state.Y_tilde.data());
523+
untransform_centroids(centroids, *state.R, d, k, state.Y_tilde.data());
496524
}
497525

498526
void super_kmeans_assign_iteration(

faiss/SuperKMeans.h

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@
1313
// "A Super Fast K-means for Indexing Vector Embeddings."
1414
// arXiv preprint arXiv:2603.20009.
1515
//
16-
// Use when: L2 metric, k >= 1024, d >= 128, dense float embeddings.
17-
// Do not use for: IP/cosine (use Clustering with cp.spherical=true), small k,
16+
// Use when: L2 metric, or spherical inner-product clustering with
17+
// cp.spherical=true, k >= 1024, d >= 128, dense float embeddings.
18+
// Do not use for: small k,
1819
// binary data (use IndexBinaryIVF), or near-unit-sphere embeddings with
1920
// k < 4096 (chi-squared assumption breaks down).
2021
//

faiss/VectorTransform.cpp

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,41 @@ void HadamardRotation::apply_noalloc(idx_t n, const float* x, float* xt) const {
513513
}
514514
}
515515

516+
void HadamardRotation::reverse_transform(idx_t n, const float* xt, float* x)
517+
const {
518+
FAISS_THROW_IF_NOT_MSG(is_trained, "Transformation not trained yet");
519+
FAISS_THROW_IF_NOT_MSG(
520+
d_in == d_out,
521+
"HadamardRotation inverse requires equal input/output dimensions");
522+
523+
const size_t p = d_out;
524+
const float inverse_scale = p * std::sqrt(static_cast<float>(p));
525+
526+
#pragma omp parallel for schedule(dynamic)
527+
for (idx_t i = 0; i < n; i++) {
528+
const float* xi = xt + i * p;
529+
float* xo = x + i * p;
530+
531+
// The inverse reverses the three sign-flip/Hadamard factors.
532+
std::memcpy(xo, xi, p * sizeof(float));
533+
fwht_inplace(xo, p);
534+
535+
for (size_t j = 0; j < p; j++) {
536+
xo[j] *= signs3[j];
537+
}
538+
fwht_inplace(xo, p);
539+
540+
for (size_t j = 0; j < p; j++) {
541+
xo[j] *= signs2[j];
542+
}
543+
fwht_inplace(xo, p);
544+
545+
for (size_t j = 0; j < p; j++) {
546+
xo[j] *= signs1[j] * inverse_scale;
547+
}
548+
}
549+
}
550+
516551
void HadamardRotation::check_identical(const VectorTransform& other) const {
517552
auto* hr = dynamic_cast<const HadamardRotation*>(&other);
518553
FAISS_THROW_IF_NOT_MSG(hr, "failed to cast to HadamardRotation");

faiss/VectorTransform.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,9 @@ struct HadamardRotation : VectorTransform {
144144

145145
void apply_noalloc(idx_t n, const float* x, float* xt) const override;
146146

147+
/// Apply the inverse transform when d_in == d_out.
148+
void reverse_transform(idx_t n, const float* xt, float* x) const override;
149+
147150
void check_identical(const VectorTransform& other) const override;
148151

149152
HadamardRotation() {}

faiss/impl/ProductQuantizer.cpp

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,9 @@ void compute_1_code(const ProductQuantizer& pq, const float* x, uint8_t* code) {
280280
} // namespace
281281

282282
void ProductQuantizer::compute_code(const float* x, uint8_t* code) const {
283-
with_simd_level([&]<SIMDLevel SL>() {
283+
// A1 includes ARM_SVE so the low-dimensional SVE nearest kernel in
284+
// fvec_L2sqr_ny_nearest is reachable from PQ encoding.
285+
with_selected_simd_levels<AVAILABLE_SIMD_LEVELS_A1>([&]<SIMDLevel SL>() {
284286
switch (nbits) {
285287
case 8:
286288
compute_1_code<PQEncoder8, SL>(*this, x, code);

faiss/python/__init__.pyi

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2628,6 +2628,7 @@ class ClusteringParameters:
26282628
init_method: ClusteringInitMethod
26292629
afkmc2_chain_length: int # chain length for AFK-MC² initialization
26302630
early_stop_threshold: float # early stop threshold [0, 1]
2631+
use_super_kmeans: bool # route through SuperKMeans when supported
26312632

26322633
def __init__(self) -> None: ...
26332634

faiss/python/extra_wrappers.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -656,8 +656,10 @@ class SuperKmeans(Kmeans):
656656
`iteration_stats`, and `assign()` surface; additionally exposes
657657
`gemm_pruning_rates`.
658658
659-
kwargs are forwarded to `SuperKMeansParameters`. Fields not present on it
660-
(e.g. `spherical`, `int_centroids`, `nredo`, `frozen_centroids`,
659+
kwargs are forwarded to `SuperKMeansParameters`. Fields inherited from
660+
`ClusteringParameters` such as `spherical` are supported (unit-normalized
661+
centroids enable inner-product clustering). Fields not present on it
662+
(e.g. `int_centroids`, `nredo`, `frozen_centroids`,
661663
`init_method`, `update_index`, `early_stop_threshold`,
662664
`progressive_dim_steps`, `gpu`) raise `AttributeError`.
663665
"""

0 commit comments

Comments
 (0)