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
32 changes: 30 additions & 2 deletions include/svs/index/vamana/vamana_build.h
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@

// stdlib
#include <algorithm>
#include <bit>
#include <concepts>
#include <memory>
#include <optional>
Expand Down Expand Up @@ -251,6 +252,33 @@ class VamanaBuilder {
size_t batchsize = lib::div_round_up(num_nodes, num_batches);
std::vector entry_points{entry_point};

// Nodes linked before this call; non-zero for dynamic inserts (includes deleted
// slots, which only makes the estimate optimistic).
const size_t preexisting =
graph_.n_nodes() > num_nodes ? graph_.n_nodes() - num_nodes : 0;
// Enough nodes per thread to keep the pool busy despite uneven search costs.
constexpr size_t nodes_per_thread = 4;
constexpr size_t min_batches = 8;
batchsize = std::max(
batchsize,
std::min(threadpool_.size() * nodes_per_thread, num_nodes / min_batches)
);

// A batch never exceeds the number of nodes it can link to; otherwise nodes in one
// batch, unable to see each other, collapse onto a hub whose pruning orphans them.
std::vector<size_t> batch_bounds;
// Bound: full-size batches + ramp-up batches + leading 0.
batch_bounds.reserve(
lib::div_round_up(num_nodes, batchsize) + std::bit_width(batchsize) + 2
);
batch_bounds.push_back(0);
while (batch_bounds.back() < num_nodes) {
size_t inserted = batch_bounds.back();
size_t size = std::clamp(preexisting + inserted, size_t{1}, batchsize);
batch_bounds.push_back(std::min(num_nodes, inserted + size));
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This scheme could produce a small last batch.
Does it make sense to reorganize the batches to insert small batches first?
I.e. insert [1, 1, 2, 4, 8, batchsize ... batchsize] instead of [1, 2, 4, 8, batchsize ... batchsize, 1].

num_batches = batch_bounds.size() - 1;

// Runtime variables
double search_time = 0;
double reverse_time = 0;
Expand All @@ -264,8 +292,8 @@ class VamanaBuilder {
auto timer = lib::Timer();
for (size_t batch_id = 0; batch_id < num_batches; ++batch_id) {
// Set up batch parameters
auto start = std::min(num_nodes, batchsize * batch_id) + base;
auto stop = std::min(num_nodes, batchsize * (batch_id + 1)) + base;
auto start = batch_bounds[batch_id] + base;
auto stop = batch_bounds[batch_id + 1] + base;

// Perform search.
// N.B. - We purposely pass "params_.alpha" instead of the external "alpha"
Expand Down
76 changes: 76 additions & 0 deletions tests/svs/index/vamana/index.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -771,3 +771,79 @@ CATCH_TEST_CASE("Vamana Index Save and Load SQ", "[vamana][index][saveload][scal
CATCH_REQUIRE(modified_distance == Catch::Approx(0.0).epsilon(1e-5));
}
}

CATCH_TEST_CASE("Vamana Index Bugcheck and Regression", "[vamana][index][regression]") {
// This test case is intended to catch any unexpected behavior or crashes in the Vamana
// index.
using namespace svs;
using namespace svs::index::vamana;

// Regression: nodes built in the same batch must not become unreachable.
//
// The data is points evenly spaced on the line x == y, with the query at the
// first point, so the expected neighbors are ids 0..k-1.
//
// The graph is built in batches. Nodes in one batch search a graph that doesn't
// contain each other yet, so each one links only to the nearest node the search can
// already reach (a "hub"), never to each other. The hub then receives a back-edge
// from every node in the batch. If that exceeds `graph_max_degree`, the hub is
// pruned, and on collinear data the pruning keeps only its one or two closest
// neighbors on each side. The other batch nodes lose their only incoming edges.
// Greedy search can never reach them afterwards, so the second build pass can't
// reconnect them either. Search then stops at a local minimum and misses the true
// nearest neighbors (e.g. returns 8, 19, 20, ... instead of 0, 1, 2, ...).
//
// Whether a given (graph_max_degree, index_size) fails depended on where batch
// boundaries fell. Fixed by starting construction with small batches that double in
// size, so early nodes can see each other.
CATCH_SECTION("Same-batch nodes stay reachable after hub pruning") {
auto build_and_search = [](size_t graph_max_degree, size_t index_size) -> bool {
svs::DistanceL2 distance_function;
float alpha = 1.2;
size_t window_size = 128;
size_t dim = 2;
size_t num_threads = 4;
size_t k = 12;

VamanaBuildParameters build_params;
build_params.alpha = alpha;
build_params.graph_max_degree = graph_max_degree;
build_params.window_size = window_size;

auto data = svs::data::SimpleData<float>(index_size, dim);
for (size_t i = 0; i < index_size; i++) {
std::vector<float> datum(dim, static_cast<float>(i + 1));
data.set_datum(i, datum);
}

auto index = svs::index::vamana::auto_build(
build_params, data, distance_function, num_threads
);

std::vector<float> query(dim, 1.0f);
svs::data::ConstSimpleDataView<float> query_view(query.data(), 1, dim);
QueryResult<size_t> result{1, k};
auto sp = index.get_search_parameters();
index.search(result.view(), query_view, sp);

CATCH_REQUIRE(result.n_queries() == 1);
CATCH_REQUIRE(result.n_neighbors() == k);

for (size_t i = 0; i < k; ++i) {
if (result.index(0, i) != i) {
// Report test failure but continue running
CATCH_CHECK(result.index(0, i) == i);
return false;
}
}
return true;
};

CATCH_CHECK(build_and_search(16, 512));
CATCH_CHECK(build_and_search(16, 1024));
CATCH_CHECK(build_and_search(32, 1024));
CATCH_CHECK(build_and_search(32, 1536));
CATCH_CHECK(build_and_search(64, 1536));
CATCH_CHECK(build_and_search(64, 3072));
}
}
Loading