Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion cpp/src/neighbors/ivf_flat/ivf_flat_search.cuh
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,11 @@ void search_impl(raft::resources const& handle,
[=] __device__(const uint32_t idx, const float dist) {
const auto query = idx / n_lists;
const auto cluster = idx % n_lists;
return dist / (q_norm_ptr[query] * index_center_norm_ptr[cluster]);
// A list whose k-means cluster ended up empty has a zero center. Define the cosine
// distance to a zero-norm vector as 1 (similarity 0), as IVF-SQ does: a NaN here is never
// selected, and with fewer finite lists than n_probes the probes get padded with list 0.
const auto denom = q_norm_ptr[query] * index_center_norm_ptr[cluster];
return denom > 0.0f ? dist / denom : 0.0f;
Comment on lines +174 to +178

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.

For cosine, the GEMM runs with alpha = -1 (L130-L162), so dist is -q.c, and this map turns it into -cos(q, c), which select_k minimizes. A list whose cluster ended up empty has a zero center, so its norm is 0 (center norms are computed in extend, ivf_flat_build.cuh L339-L355), and dist / denom becomes 0 / 0 = NaN. select_k never picks a NaN column. When fewer than n_probes columns are finite, the remaining probe slots get index 0, so list 0 is scanned several times.

Returning 0 here means similarity 0, i.e. cosine distance 1. A zero-norm center then ranks after every center with positive similarity, so it is only probed when there are not enough better lists. This matches IVF-SQ's coarse search (ivf_sq_search.cuh L460-L474) and the convention in the cuTile 1-NN kernel from #2552 (fused_1nn_kernel.py L132-L137).

The guard also changes the zero-query case: every list now gets 0, so the probes are a select_k tie-break. Before, every list was NaN, so all probe slots were list 0.

},
raft::make_const_mdspan(distance_buffer_dev_view));
}
Expand Down
80 changes: 80 additions & 0 deletions cpp/tests/neighbors/ann_ivf_flat/test_float_int64_t.cu
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@
#include <raft/core/resource/cuda_stream.hpp>
#include <raft/linalg/init.cuh>
#include <raft/matrix/init.cuh>
#include <raft/random/rng.cuh>

#include <algorithm>
#include <numeric>
#include <optional>
#include <sstream>
#include <vector>

namespace cuvs::neighbors::ivf_flat {

Expand Down Expand Up @@ -48,6 +51,83 @@ TEST(IVFFlatSerialization, StreamRoundTrip)
EXPECT_EQ(loaded.n_lists(), index.n_lists());
}

// k-means gives an empty cluster a zero center. With the cosine metric, the coarse search must
// still rank such lists instead of producing NaN distances: when fewer than n_probes lists have a
// finite distance, the NaN columns are never selected and the remaining probe slots are padded
// with list 0, which then is scanned several times and returns duplicated neighbors.
TEST(AnnIVFFlatTest, CosineZeroNormCenters)
{
raft::resources handle;
auto stream = raft::resource::get_cuda_stream(handle);

constexpr int64_t n_rows = 10000;
constexpr int64_t n_queries = 500;
constexpr int64_t dim = 8;
constexpr uint32_t n_lists = 1024;
constexpr uint32_t n_zero_lists = 1000;
constexpr uint32_t n_probes = 40;
constexpr uint32_t k = 16;

auto data = raft::make_device_matrix<float, int64_t>(handle, n_rows, dim);
auto queries = raft::make_device_matrix<float, int64_t>(handle, n_queries, dim);
raft::random::RngState rng(1234ULL);
raft::random::uniform(handle, rng, data.data_handle(), data.size(), 0.1f, 2.0f);
raft::random::uniform(handle, rng, queries.data_handle(), queries.size(), 0.1f, 2.0f);

index_params params;
params.n_lists = n_lists;
params.metric = cuvs::distance::DistanceType::CosineExpanded;
params.add_data_on_build = false;
auto idx = build(handle, params, raft::make_const_mdspan(data.view()));

// Zero the centers of lists 1..n_zero_lists, as if k-means had left these clusters empty. Fewer
// lists than n_probes keep a non-zero center, and list 0 is one of them.
std::vector<float> centers_h(idx.centers().size());
raft::update_host(centers_h.data(), idx.centers().data_handle(), centers_h.size(), stream);
raft::resource::sync_stream(handle);
std::fill(centers_h.begin() + dim, centers_h.begin() + (n_zero_lists + 1) * dim, 0.0f);
raft::update_device(idx.centers().data_handle(), centers_h.data(), centers_h.size(), stream);
extend(handle, raft::make_const_mdspan(data.view()), std::nullopt, &idx);
Comment on lines +83 to +90

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.

The test builds the bad case directly instead of relying on k-means to leave lists empty. It builds with add_data_on_build = false, zeroes centers 1..1000, and then calls extend. Center norms start unset (ivf_flat_index.cpp L43) and are computed on the first extend, so they reflect the zeroed centers. That leaves 24 lists with a non-zero center, fewer than n_probes = 40, so without the guard 16 probe slots are padded with list 0. With the guard, all 24 non-empty lists are probed, the search is exhaustive over the data, and it is compared with naive_knn at a 0.99 recall threshold.

Question for reviewers: editing idx.centers() before the first extend depends on norms being computed lazily. Is that acceptable, or would you prefer to create the empty lists through the data, for example int8 data with few distinct directions, as in the original failure? That would depend on k-means behavior and would be harder to make deterministic.


// All data rows are in the lists with a non-zero center, and all of those lists are probed.
search_params sparams;
sparams.n_probes = n_probes;
auto neighbors = raft::make_device_matrix<int64_t, int64_t>(handle, n_queries, k);
auto distances = raft::make_device_matrix<float, int64_t>(handle, n_queries, k);
search(handle,
sparams,
idx,
raft::make_const_mdspan(queries.view()),
neighbors.view(),
distances.view());

auto naive_neighbors = raft::make_device_matrix<int64_t, int64_t>(handle, n_queries, k);
auto naive_distances = raft::make_device_matrix<float, int64_t>(handle, n_queries, k);
cuvs::neighbors::naive_knn<float, float, int64_t>(handle,
naive_distances.data_handle(),
naive_neighbors.data_handle(),
queries.data_handle(),
data.data_handle(),
n_queries,
n_rows,
dim,
k,
params.metric);

std::vector<int64_t> neighbors_h(n_queries * k), naive_neighbors_h(n_queries * k);
std::vector<float> distances_h(n_queries * k), naive_distances_h(n_queries * k);
raft::update_host(neighbors_h.data(), neighbors.data_handle(), neighbors_h.size(), stream);
raft::update_host(distances_h.data(), distances.data_handle(), distances_h.size(), stream);
raft::update_host(
naive_neighbors_h.data(), naive_neighbors.data_handle(), naive_neighbors_h.size(), stream);
raft::update_host(
naive_distances_h.data(), naive_distances.data_handle(), naive_distances_h.size(), stream);
raft::resource::sync_stream(handle);

EXPECT_TRUE(eval_neighbours(
naive_neighbors_h, neighbors_h, naive_distances_h, distances_h, n_queries, k, 0.001, 0.99));
}

TEST(AnnIVFFlatTest, RepeatedExtendCopyPreservesSharedListWithinCapacity)
{
raft::resources handle;
Expand Down
Loading