Skip to content

Commit cb9a72a

Browse files
Lei Huangmeta-codesync[bot]
authored andcommitted
Reuse the HNSW visited table across searches instead of reallocating per call (#5448)
Summary: Pull Request resolved: #5448 ## Background Every `IndexHNSW::search()` call enters an `#pragma omp parallel` region in which each thread constructs its own `VisitedTable` via `VisitedTable::create(ntotal, ...)`. For the versioned-array strategy (`VisitedTableVector`), a fresh array allocation is paid **per thread, per Search() call** - this is an O(ntotal) allocation plus zero-fill overhead. It's freed every time the region exits. ## Problem When a statically-built index is searched repeatedly with small or even single-query batches, this per-call alloc+zero can dominate the actual graph traversal (the search visits only a handful of nodes, but still pays to allocate and zero the whole `ntotal`-byte array). ## Solution This adds `VisitedTable::get_reusable()` API to return a reference to the `thread_local` VisitedTable. The O(size) versioned array is **allocated once per thread and reused across all subsequent Search() calls**. This changed is applied to 3 code paths for search (`IndexHNSW.cpp`): - `hnsw_search` - `search_level_0` - only used by IndexHNSWCagra - `IndexHNSWCagra::range_search` - only used by IndexHNSWCagra Why `thread_local` rather than a member of the index: - It is inherently per-OS-thread, so it survives across `search()` calls (OpenMP reuses its worker pool) and each thread gets its own table with no cross-thread coordination. - faiss supports calling `const` search concurrently on one index from multiple threads. A member array indexed by `omp_get_thread_num()` would let two concurrent searches race on slot 0; `thread_local` cannot, since each OS thread has its own copy. - The table grows on demand (`ensure_size`) if `ntotal` increases and never shrinks. Retained memory is bounded by the array-vs-hash-set threshold (D112025912). ## Correctness `get_reusable()` calls `advance()` before returning the table. A prior search that threw exception without correctly `advance()` may have left version stamps NOT updated; without the reset those would read as spurious "visited" hits on the next search on that thread. `advance()` - might be redundant - in `get_reusable()` guarantees correct query version id. ## Minor notes The `IndexHNSW2Level` mixed-search path is deliberately left on per-search `create()` rather than `get_reusable()`. It is a legacy path that uses the tri-state visited flags of `search_from_candidates_2` (two `advance()` calls per query), whose reset semantics differ from the bi-state paths above; keeping a fresh per-search table there leaves its behavior unchanged and scopes reuse to the paths where a single `advance()` at handout is correct. Reviewed By: mnorris11, pankajsingh88 Differential Revision: D112042940 fbshipit-source-id: c6be0effdb2f52d21f6bb16db2d2ad0972a71bc0
1 parent 7437cac commit cb9a72a

4 files changed

Lines changed: 113 additions & 7 deletions

File tree

faiss/IndexHNSW.cpp

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -280,12 +280,12 @@ void hnsw_search(
280280

281281
#pragma omp parallel if (i1 - i0 > 1)
282282
{
283-
std::unique_ptr<VisitedTable> vt;
283+
VisitedTable* vt = nullptr;
284284
std::unique_ptr<typename BlockResultHandler::SingleResultHandler>
285285
res;
286286
std::unique_ptr<DistanceComputer> dis;
287287
try {
288-
vt = VisitedTable::create(
288+
vt = &VisitedTable::get_reusable(
289289
index->ntotal, hnsw.use_visited_hashset);
290290
res = std::make_unique<
291291
typename BlockResultHandler::SingleResultHandler>(bres);
@@ -479,11 +479,11 @@ void IndexHNSW::search_level_0(
479479
{
480480
std::unique_ptr<DistanceComputer> qdis;
481481
HNSWStats search_stats;
482-
std::unique_ptr<VisitedTable> vt;
482+
VisitedTable* vt = nullptr;
483483
std::unique_ptr<typename RH::SingleResultHandler> res;
484484
try {
485485
qdis.reset(storage_distance_computer(storage));
486-
vt = VisitedTable::create(
486+
vt = &VisitedTable::get_reusable(
487487
hnsw_ntotal, hnsw.use_visited_hashset);
488488
res = std::make_unique<typename RH::SingleResultHandler>(bres);
489489
} catch (...) {
@@ -1208,11 +1208,11 @@ void IndexHNSWCagra::range_search(
12081208

12091209
RangeQueryResult& qres = pres.new_result(i);
12101210
RangeResultHandler<C> res(&qres, radius);
1211-
std::unique_ptr<VisitedTable> vt =
1212-
VisitedTable::create(ntotal, hnsw.use_visited_hashset);
1211+
VisitedTable& vt = VisitedTable::get_reusable(
1212+
ntotal, hnsw.use_visited_hashset);
12131213
HNSWStats stats;
12141214
hnsw.search_level_0(
1215-
*dis, res, 1, &nearest, &nearest_d, 1, stats, *vt, params);
1215+
*dis, res, 1, &nearest, &nearest_d, 1, stats, vt, params);
12161216
n1 += stats.n1;
12171217
n2 += stats.n2;
12181218
ndis += stats.ndis;

faiss/impl/VisitedTable.cpp

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,22 @@ std::unique_ptr<VisitedTable> VisitedTable::create(
3333
return std::make_unique<VisitedTableVector>(size);
3434
}
3535

36+
VisitedTable& VisitedTable::get_reusable(
37+
size_t size,
38+
std::optional<bool> use_hashset) {
39+
bool use_set =
40+
use_hashset.value_or(size >= visited_table_hashset_threshold);
41+
if (use_set) {
42+
thread_local VisitedTableSet tls_set;
43+
tls_set.advance();
44+
return tls_set;
45+
}
46+
thread_local VisitedTableVector tls_vec(0);
47+
tls_vec.ensure_size(size);
48+
tls_vec.advance();
49+
return tls_vec;
50+
}
51+
3652
void VisitedTableVector::advance() {
3753
if (visno < 254) {
3854
// 254 rather than 255 because sometimes we use visno and visno+1

faiss/impl/VisitedTable.h

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,18 @@ struct VisitedTable {
4848
static std::unique_ptr<VisitedTable> create(
4949
size_t size,
5050
std::optional<bool> use_hashset = std::nullopt);
51+
52+
/// Returns a thread-local, reusable table sized for at least `size` and
53+
/// reset to a clean state. Unlike create(), it does not allocate on each
54+
/// call: the O(size) versioned array is allocated once per thread and
55+
/// reused across searches, avoiding a per-search alloc+zero of the whole
56+
/// array when a static index is searched repeatedly.
57+
///
58+
/// The returned reference is owned by thread-local storage: do not delete
59+
/// it and do not use it beyond the current search on the calling thread.
60+
static VisitedTable& get_reusable(
61+
size_t size,
62+
std::optional<bool> use_hashset = std::nullopt);
5163
};
5264

5365
/// Set-based implementation using unordered_set.
@@ -87,6 +99,14 @@ struct VisitedTableVector FAISS_FINAL : VisitedTable {
8799

88100
explicit VisitedTableVector(size_t size) : visited(size, 0) {}
89101

102+
/// Grow so indices in [0, size) are valid; new slots read as unvisited.
103+
/// Never shrinks, so capacity is retained when the table is reused.
104+
void ensure_size(size_t size) {
105+
if (visited.size() < size) {
106+
visited.resize(size, 0);
107+
}
108+
}
109+
90110
bool set(size_t no) final {
91111
if (visited[no] == visno) {
92112
return false;

tests/test_hnsw.cpp

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -750,3 +750,73 @@ TEST_F(HNSWTest, TEST_search_level_0) {
750750
EXPECT_GT(stats1.n1, stats2.n1);
751751
EXPECT_GT(stats1.n2, stats2.n2);
752752
}
753+
754+
TEST(VisitedTableReuse, ReusesGrowsAndResets) {
755+
// get_reusable() hands back the same thread-local object across calls
756+
// (no per-call allocation) ...
757+
faiss::VisitedTable& t1 = faiss::VisitedTable::get_reusable(100);
758+
faiss::VisitedTable& t2 = faiss::VisitedTable::get_reusable(100);
759+
EXPECT_EQ(&t1, &t2);
760+
761+
// ... and hands it back clean: a mark set before re-acquiring must not
762+
// survive, since a reused table may carry stamps from a prior search.
763+
t2.set(42);
764+
EXPECT_TRUE(t2.get(42));
765+
faiss::VisitedTable& t3 = faiss::VisitedTable::get_reusable(100);
766+
EXPECT_EQ(&t2, &t3);
767+
EXPECT_FALSE(t3.get(42));
768+
769+
// Growing to a larger size keeps the same object and exposes the new range.
770+
faiss::VisitedTable& t4 = faiss::VisitedTable::get_reusable(1000);
771+
EXPECT_EQ(&t3, &t4);
772+
EXPECT_FALSE(t4.get(900));
773+
EXPECT_TRUE(t4.set(900));
774+
EXPECT_TRUE(t4.get(900));
775+
}
776+
777+
// Restores the process-global OpenMP thread count on scope exit, so a test that
778+
// changes it via omp_set_num_threads does not leak the setting into later
779+
// tests.
780+
struct ScopedOmpThreads {
781+
int saved = omp_get_max_threads();
782+
~ScopedOmpThreads() {
783+
omp_set_num_threads(saved);
784+
}
785+
};
786+
787+
TEST_F(HNSWTest, TEST_search_reuse_correctness) {
788+
ScopedOmpThreads omp_guard;
789+
790+
std::vector<faiss::idx_t> I1(k * nq), I2(k * nq);
791+
std::vector<float> D1(k * nq), D2(k * nq);
792+
793+
// Two back-to-back searches must be identical: the reused visited table
794+
// must not carry state across search() calls.
795+
omp_set_num_threads(1);
796+
index->search(nq, xq->data(), k, D1.data(), I1.data());
797+
index->search(nq, xq->data(), k, D2.data(), I2.data());
798+
EXPECT_EQ(I1, I2);
799+
EXPECT_EQ(D1, D2);
800+
801+
// The reused versioned-array path must match a hash-set reference.
802+
std::vector<faiss::idx_t> Iref(k * nq);
803+
std::vector<float> Dref(k * nq);
804+
index->hnsw.use_visited_hashset = true;
805+
index->search(nq, xq->data(), k, Dref.data(), Iref.data());
806+
index->hnsw.use_visited_hashset = false;
807+
std::vector<faiss::idx_t> Iarr(k * nq);
808+
std::vector<float> Darr(k * nq);
809+
index->search(nq, xq->data(), k, Darr.data(), Iarr.data());
810+
EXPECT_EQ(Iref, Iarr);
811+
EXPECT_EQ(Dref, Darr);
812+
813+
// Multi-threaded search must match single-threaded: each thread reuses its
814+
// own thread-local table.
815+
index->hnsw.use_visited_hashset = std::nullopt;
816+
omp_set_num_threads(4);
817+
std::vector<faiss::idx_t> Imt(k * nq);
818+
std::vector<float> Dmt(k * nq);
819+
index->search(nq, xq->data(), k, Dmt.data(), Imt.data());
820+
EXPECT_EQ(I1, Imt);
821+
EXPECT_EQ(D1, Dmt);
822+
}

0 commit comments

Comments
 (0)