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
54 changes: 30 additions & 24 deletions cpp/src/neighbors/ivf_rabitq/gpu_index/ivf_gpu.cu
Original file line number Diff line number Diff line change
Expand Up @@ -719,47 +719,53 @@ void IVFGPU::construct_on_gpu_streaming(const float* host_data,
cluster_idx++;
}

if (batch_vectors == 0) break; // No more clusters to process
// Every cluster fits into a batch (checked above), so each batch makes progress.
RAFT_EXPECTS(cluster_idx > batch_start_cluster, "IVF-RaBitQ streaming batch is empty");
Comment on lines +722 to +723

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 used to be if (batch_vectors == 0) break;. A batch could only be empty in two cases:

  1. a cluster larger than the batch, which the RAFT_EXPECTS at L680-L684 already rejects;
  2. a batch of only empty clusters. An empty cluster always fits, so the inner loop folds empty clusters into the current batch, and every later batch starts with the non-empty cluster that didn't fit. So this case needs every list to be empty.

A silent break would have skipped the rest of the clusters. I replaced it with an assertion of the invariant and put the copies below behind batch_vectors > 0.


// -------------------------
// 11. Gather and transfer batch data to GPU using reordered PIDs
// -------------------------

// First, copy the reordered PIDs for this batch to host (reuse preallocated buffer)
raft::copy(batch_pids.data_handle(), d_flat_pids + batch_start_offset, batch_vectors, stream_);
raft::resource::sync_stream(handle_);
// A batch that only contains empty clusters has no data to transfer, but the clusters still
// need their rotated centroids below.
if (batch_vectors > 0) {
// First, copy the reordered PIDs for this batch to host (reuse preallocated buffer)
raft::copy(
batch_pids.data_handle(), d_flat_pids + batch_start_offset, batch_vectors, stream_);
raft::resource::sync_stream(handle_);

// Gather data on host using the reordered PIDs (reuse preallocated buffer)
// Use OpenMP parallel region to leverage persistent thread pool across batches
// Gather data on host using the reordered PIDs (reuse preallocated buffer)
// Use OpenMP parallel region to leverage persistent thread pool across batches
#pragma omp parallel num_threads(num_threads)
{
size_t tid = cuvs::core::omp::get_thread_num();
size_t chunk_size = batch_vectors / num_threads;
size_t start = tid * chunk_size;
size_t end = (tid == num_threads - 1) ? batch_vectors : start + chunk_size;

for (size_t i = start; i < end; ++i) {
std::memcpy(&batch_data.data_handle()[i * num_dimensions],
&host_data[batch_pids.data_handle()[i] * num_dimensions],
num_dimensions * sizeof(float));
{
size_t tid = cuvs::core::omp::get_thread_num();
size_t chunk_size = batch_vectors / num_threads;
size_t start = tid * chunk_size;
size_t end = (tid == num_threads - 1) ? batch_vectors : start + chunk_size;

for (size_t i = start; i < end; ++i) {
std::memcpy(&batch_data.data_handle()[i * num_dimensions],
&host_data[batch_pids.data_handle()[i] * num_dimensions],
num_dimensions * sizeof(float));
}
}
}
// OpenMP implicit barrier ensures all threads complete before continuing
// OpenMP implicit barrier ensures all threads complete before continuing

// Transfer batch to GPU (reuse preallocated buffer)
raft::copy(d_batch_data.data_handle(),
batch_data.data_handle(),
batch_vectors * num_dimensions,
stream_);
// Transfer batch to GPU (reuse preallocated buffer)
raft::copy(d_batch_data.data_handle(),
batch_data.data_handle(),
batch_vectors * num_dimensions,
stream_);
}

// -------------------------
// 12. Quantize each cluster in the batch
// -------------------------

size_t batch_offset = 0;
for (size_t c = batch_start_cluster; c < cluster_idx; ++c) {
// Empty clusters are quantized too, so that their rotated centroids are initialized.
size_t cluster_size = h_cluster_meta[c].num;
Comment on lines +767 to 768

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 is the streaming-path bug. if (cluster_size == 0) continue; skipped empty lists entirely, so their slots in d_rotated_centroids were never written. That buffer is allocated uninitialized at L690-L691, and initializer->AddVectors then uses those slots as the centroids for probe selection. While reproducing this, an n_probes = 1 search on a streaming-built index with empty lists returned the wrong rows for 2 of 16 queries (#2783 comment). Empty lists now go through quantize_batch_opt_contiguous, which writes the rotated centroid and returns early.

if (cluster_size == 0) continue;

const float* cur_centroid = device_centroids + c * num_dimensions;
float* cur_rotated_c = d_rotated_centroids.data_handle() + c * num_padded_dim;
Expand Down
12 changes: 12 additions & 0 deletions cpp/src/neighbors/ivf_rabitq/gpu_index/quantizer_gpu.cu
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,9 @@ void data_transformation_batch_opt(const float* d_data,
// 5. Save the rotated centroid: copy CP into d_rotated_c.
raft::copy(d_rotated_c, d_CP, D, stream);

// An empty list only needs its rotated centroid; a launch with zero blocks is invalid.
if (num_points == 0) { return; }
Comment on lines +523 to +524

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 is the launch that threw in the nightly. On main it is quantizer_gpu.cu L523-L538, and L538 is the cudaPeekAtLastError() in the error message. With num_points == 0, gridDim.x is 0, and a launch with zero blocks returns cudaErrorInvalidValue: invalid argument.

Returning here still produces the rotated centroid. gatherAndPadKernel writes the centroid as row num_points of the padded buffer, and the rotation runs on num_points + 1 rows, so steps 2-5 above still work with zero data rows. data_transformation_batch_opt_contiguous (the streaming path) gets the same guard further down.


// 6. Launch the single FUSED kernel for subtract, normalize, and binarize.
const unsigned int FusedBlockSize = 256; // A good default, can be tuned.
dim3 gridDim(num_points);
Expand Down Expand Up @@ -637,6 +640,9 @@ void DataQuantizerGPU::quantize_batch_opt(const float* d_data,
D,
handle_);

// An empty list has no codes or factors to compute.
if (num_points == 0) { return; }
Comment on lines +643 to +644

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 kernels after the transformation also launch one block per point: rabitq_codes_and_factors_fused uses dim3 grid(num_points) (L556-L569), and exrabitq_codes_and_factors_fused uses dim3 gridDim(num_points) (L590-L612). An empty list has no codes or factors to write, so we return once the rotated centroid is stored. quantize_batch_opt_contiguous gets the same guard below.


rabitq_codes_and_factors_fused(d_rotated_c,
d_bin_XP.data_handle(),
d_XP.data_handle(),
Expand Down Expand Up @@ -714,6 +720,9 @@ void data_transformation_batch_opt_contiguous(const float* d_contiguous_data,
// 5. Save the rotated centroid: copy CP into d_rotated_c.
raft::copy(d_rotated_c, d_CP, D, stream);

// An empty list only needs its rotated centroid; a launch with zero blocks is invalid.
if (num_points == 0) { return; }

// 6. Launch the single FUSED kernel for subtract, normalize, and binarize.
const unsigned int FusedBlockSize = 256; // A good default, can be tuned.
dim3 gridDim(num_points);
Expand Down Expand Up @@ -758,6 +767,9 @@ void DataQuantizerGPU::quantize_batch_opt_contiguous(const float* d_contiguous_d
D,
handle_);

// An empty list has no codes or factors to compute.
if (num_points == 0) { return; }

rabitq_codes_and_factors_fused(d_rotated_c,
d_bin_XP.data_handle(),
d_XP.data_handle(),
Expand Down
119 changes: 119 additions & 0 deletions cpp/tests/neighbors/ann_ivf_rabitq.cuh
Original file line number Diff line number Diff line change
Expand Up @@ -396,4 +396,123 @@ inline auto var_search_mode_1_bit() -> test_cases_t
#define INSTANTIATE(type, vals) \
INSTANTIATE_TEST_SUITE_P(IvfRabitq, type, ::testing::ValuesIn(vals)); /* NOLINT */

/* Empty lists */

enum class dataset_input { device, host, host_streaming };

struct ivf_rabitq_empty_lists_inputs {
uint32_t dim;
dataset_input input;
};

inline auto operator<<(std::ostream& os, const ivf_rabitq_empty_lists_inputs& p) -> std::ostream&
{
const char* input = p.input == dataset_input::device ? "device"
: p.input == dataset_input::host ? "host"
: "host_streaming";
return os << "ivf_rabitq_empty_lists_inputs {.dim = " << p.dim << ", .input = " << input << "}";
}

/**
* The dataset has fewer distinct rows than there are lists. Identical rows are always assigned to
* the same list, so k-means is guaranteed to leave some lists empty. The index must still build
* from device and host input, and search must still find the nearest rows.
*/
class ivf_rabitq_empty_lists_test : public ::testing::TestWithParam<ivf_rabitq_empty_lists_inputs> {
protected:
void run()
{
constexpr int64_t kRows = 4096;
constexpr int64_t kDistinct = 8;
constexpr int64_t kQueries = 2 * kDistinct;
constexpr int64_t kK = 10;
constexpr uint32_t kLists = 32;
constexpr float kSpacing = 0.25f;
constexpr float kOffset = 0.1f;

auto ps = GetParam();
auto dim = static_cast<int64_t>(ps.dim);
auto stream = raft::resource::get_cuda_stream(handle_);

// Row i is the point kSpacing * (i % kDistinct) on the diagonal. Query q is offset from the
// distinct row q % kDistinct by +-kOffset in every dimension, so that row is its unique nearest
// distinct row at squared L2 distance kOffset^2 * dim.
auto h_dataset = raft::make_host_matrix<float, int64_t>(kRows, dim);
for (int64_t i = 0; i < kRows; i++) {
for (int64_t j = 0; j < dim; j++) {
h_dataset(i, j) = kSpacing * static_cast<float>(i % kDistinct);
}
}
auto h_queries = raft::make_host_matrix<float, int64_t>(kQueries, dim);
for (int64_t i = 0; i < kQueries; i++) {
for (int64_t j = 0; j < dim; j++) {
h_queries(i, j) =
kSpacing * static_cast<float>(i % kDistinct) + (i < kDistinct ? kOffset : -kOffset);
}
}
auto d_dataset = raft::make_device_matrix<float, int64_t>(handle_, kRows, dim);
auto d_queries = raft::make_device_matrix<float, int64_t>(handle_, kQueries, dim);
raft::copy(d_dataset.data_handle(), h_dataset.data_handle(), h_dataset.size(), stream);
raft::copy(d_queries.data_handle(), h_queries.data_handle(), h_queries.size(), stream);

index_params ipams;
ipams.n_lists = kLists;
if (ps.input == dataset_input::host_streaming) {
ipams.force_streaming = true;
ipams.streaming_batch_size = kRows;
}
auto idx = ps.input == dataset_input::device
? build(handle_, ipams, raft::make_const_mdspan(d_dataset.view()))
: build(handle_, ipams, raft::make_const_mdspan(h_dataset.view()));

// Probe every list, because an empty list's centroid can be closer to a query than the
// centroid of the list that holds its neighbors.
search_params sparams;
sparams.n_probes = kLists;
Comment on lines +468 to +471

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.

Question for reviewers: empty lists stay in the index with whatever centroid k-means gave them, so they can still be selected as probes, which wastes a probe slot. This test avoids the issue by probing every list. Should search skip empty lists (for example, by giving them an infinite coarse distance), or is that out of scope here?

Because every list is probed, the 5 host_streaming cases also pass without this fix: for an empty list, the rotated centroid only affects whether it is picked as a probe. They cover the streaming build's handling of empty clusters, but not the uninitialized-centroid bug itself.

Question for reviewers: do you want coverage for that bug too? It would need n_probes < n_lists, which brings back the question above of how empty lists rank. Or is the current coverage enough?

auto neighbors = raft::make_device_matrix<int64_t, int64_t>(handle_, kQueries, kK);
auto distances = raft::make_device_matrix<float, int64_t>(handle_, kQueries, kK);
search(handle_,
sparams,
idx,
raft::make_const_mdspan(d_queries.view()),
neighbors.view(),
distances.view());

std::vector<int64_t> h_neighbors(kQueries * kK);
std::vector<float> h_distances(kQueries * kK);
raft::copy(h_neighbors.data(), neighbors.data_handle(), h_neighbors.size(), stream);
raft::copy(h_distances.data(), distances.data_handle(), h_distances.size(), stream);
raft::resource::sync_stream(handle_);

// Every row has kRows / kDistinct > kK copies, so all kK neighbors are copies of the nearest
// distinct row. The next nearest distinct row is (kSpacing - kOffset)^2 * dim away.
const float expected_dist = kOffset * kOffset * static_cast<float>(dim);
for (int64_t q = 0; q < kQueries; q++) {
for (int64_t j = 0; j < kK; j++) {
auto neighbor = h_neighbors[q * kK + j];
auto dist = h_distances[q * kK + j];
ASSERT_TRUE(neighbor >= 0 && neighbor < kRows)
<< ps << ": query " << q << ", neighbor " << j << " = " << neighbor;
ASSERT_EQ(neighbor % kDistinct, q % kDistinct)
<< ps << ": query " << q << ", neighbor " << j << " = " << neighbor;
ASSERT_NEAR(dist, expected_dist, 0.1f * expected_dist)
<< ps << ": query " << q << ", neighbor " << j << " = " << neighbor;
}
}
}

raft::resources handle_;
};

inline auto empty_lists_inputs() -> std::vector<ivf_rabitq_empty_lists_inputs>
{
std::vector<ivf_rabitq_empty_lists_inputs> xs;
for (uint32_t dim : {1, 2, 7, 64, 100}) {
for (auto input : {dataset_input::device, dataset_input::host, dataset_input::host_streaming}) {
xs.push_back({dim, input});
}
}
return xs;
}

} // namespace cuvs::neighbors::ivf_rabitq
5 changes: 5 additions & 0 deletions cpp/tests/neighbors/ann_ivf_rabitq/test_float_int64_t.cu
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,9 @@ INSTANTIATE(f32_f32_i64,
defaults() + small_dims() + big_dims() + var_n_probes() + var_k() + var_bits_per_dim() +
var_search_mode() + var_search_mode_1_bit());

TEST_P(ivf_rabitq_empty_lists_test, build_search) { this->run(); } // NOLINT
INSTANTIATE_TEST_SUITE_P(IvfRabitq,
ivf_rabitq_empty_lists_test,
::testing::ValuesIn(empty_lists_inputs())); // NOLINT

} // namespace cuvs::neighbors::ivf_rabitq
Loading