Skip to content

Commit 3dd2f60

Browse files
Michael Norrisfacebook-github-bot
authored andcommitted
faiss HNSW: make graph construction deterministic by default (remove lock-based build) (#5486)
Summary: TLDR: makes the deterministic HNSW graph build the default (and only) float build path, and removes the legacy lock-based one. Inspired by ParlayANN. The deterministic build is reproducible AND faster than the lock-based build at every scale and thread count we measured. -- similarities to parlayANN: - add vertices in doubling batches against frozen snapshot - defer adding reciprocal edges immediately, add them later after parallel phase differences from ParlayANN: - original ParlanANN targets flat graphs like Vamana - re-uses Faiss HNSW pruning in `shrink_neighbor_list` --- AI (with a bunch of edits) explanation in more detail: -- What changed - `IndexHNSW::add` now always uses the deterministic, lock-free build. The lock-based `hnsw_add_vertices` (float) and the opt-in `deterministic_build` flag are removed. - The deterministic path now supports the CAGRA level-0 import configuration: `init_level0=false` skips the level-0-only bucket (level 0 is supplied by the imported CAGRA graph), and `keep_max_size_level0` fills the base layer to 2*M. So `IndexHNSWCagra` (CPU) and `GpuIndexCagra::copyTo(IndexHNSWCagra*)` build through the deterministic path. - The binary `IndexBinaryHNSW` keeps its own independent lock-based build (it has no deterministic variant). Background -- HNSW construction in Faiss was non-deterministic under parallel builds: multiple runs of `IndexHNSW::add` with the same data and seeds could produce different graphs, a problem for persistence, crash recovery, and replication (the ParlayANN motivation, https://arxiv.org/abs/2305.04359). Sources of non-determinism were: (1) the reciprocal-link write race in `add_links_starting_from_impl`; (2) floating-point distance ties resolved in heap/visitation order; (3) the entry-point bootstrap `#pragma omp critical` race. Algorithm (adapted from ParlayANN to Faiss's level-batched structure): - Per level bucket (highest first, deterministic shuffle), points are inserted in prefix-doubling sub-batches (batch sizes 1, 2, 4, ... capped at 2% of the index). - Phase A (`HNSW::compute_forward_links_deterministic`, parallel): each point greedily descends and computes its forward links against the immutable snapshot from the end of the previous sub-batch, writing only its own neighbor slots. Reciprocal-edge requests are collected, not applied, so this phase is race-free. - Phase B (`HNSW::merge_reverse_links_deterministic`, parallel): reverse edges are grouped by destination with a fixed-size 256-bucket radix partition on the low bits of `dest` (a small constant bucket count, independent of `ntotal` and thread count, so grouping stays O(edges) in memory), each bucket sorted by `(level, dest)` and merged in parallel. Every affected node is merged exactly once in a total order (distance, ties by id) and re-pruned with the same RNG heuristic. Because every `dest` maps to exactly one bucket, distinct nodes touch disjoint slots (no locks) and the merge is order- and thread-count-independent. The Phase-B parallel-for uses `schedule(static)` — the libomp dynamic dispatcher segfaults in some build configs (the pre-existing lock-based build carried the same warning). Guarantee: the resulting graph is reproducible across runs at a fixed thread count and, in practice, across thread counts (the merge is fully order-independent). Recall matches the previous default at every efSearch. ## Performance: build time (40M, d=128, M=32, efC=64, 166 threads) 10-round interleaved timing study (one deterministic + one lock-based build per round, so both see identical host conditions): deterministic per-round s: 285.58 275.03 280.84 272.62 273.73 272.61 272.05 272.90 269.87 272.29 lock-based per-round s: 306.23 352.97 322.76 294.23 339.32 303.04 291.46 341.96 359.31 282.92 deterministic: min=269.87 mean=274.75 median=272.76 max=285.58 std=4.53 lock-based: min=282.92 mean=319.42 median=314.50 max=359.31 std=26.12 det/lock: mean=0.860 (deterministic ~14% faster), median=0.867 The deterministic build is ~14% faster than the removed lock-based build at 40M and ~6x more stable run-to-run (std 4.53s vs 26.12s), since it does not depend on lock-contention timing. Peak RSS ~66GB vs ~56GB. Recall matches at every efSearch (byte-identical graph across builds). ## Performance: search time Back on the deterministic HEAD, tree clean. Here's the matched A/B — same 40M synthetic data, same machine (AMD Genoa, 166 cores), search_repeat=100, deterministic (my HEAD) vs lock-based (parent commit). Since my diff doesn't touch search() at all, any difference is purely graph structure + measurement noise. Search QPS: deterministic vs lock-based (40M synthetic, repeat=100) HNSW16 ┌──────────┬─────────────────┬─────────┬──────────┬───────┐ │ efSearch │ recall det/lock │ QPS det │ QPS lock │ Δ │ ├──────────┼─────────────────┼─────────┼──────────┼───────┤ │ 64 │ 0.828/0.820 │ 170,329 │ 177,995 │ −4.3% │ ├──────────┼─────────────────┼─────────┼──────────┼───────┤ │ 128 │ 0.866/0.862 │ 112,727 │ 110,727 │ +1.8% │ ├──────────┼─────────────────┼─────────┼──────────┼───────┤ │ 256 │ 0.886/0.888 │ 59,815 │ 56,784 │ +5.3% │ └──────────┴─────────────────┴─────────┴──────────┴───────┘ HNSW32 ┌──────────┬─────────────────┬─────────┬──────────┬───────┐ │ efSearch │ recall det/lock │ QPS det │ QPS lock │ Δ │ ├──────────┼─────────────────┼─────────┼──────────┼───────┤ │ 64 │ 0.935/0.930 │ 110,186 │ 108,411 │ +1.6% │ ├──────────┼─────────────────┼─────────┼──────────┼───────┤ │ 128 │ 0.958/0.953 │ 68,019 │ 66,308 │ +2.6% │ ├──────────┼─────────────────┼─────────┼──────────┼───────┤ │ 256 │ 0.965/0.960 │ 37,624 │ 35,828 │ +5.0% │ └──────────┴─────────────────┴─────────┴──────────┴───────┘ HNSW32,SQ8 ┌──────────┬─────────────────┬─────────┬──────────┬────────┐ │ efSearch │ recall det/lock │ QPS det │ QPS lock │ Δ │ ├──────────┼─────────────────┼─────────┼──────────┼────────┤ │ 64 │ 0.926/0.934 │ 220,713 │ 198,325 │ +11.3% │ ├──────────┼─────────────────┼─────────┼──────────┼────────┤ │ 128 │ 0.948/0.953 │ 117,504 │ 129,173 │ −9.0% │ ├──────────┼─────────────────┼─────────┼──────────┼────────┤ │ 256 │ 0.961/0.963 │ 58,582 │ 65,551 │ −10.6% │ └──────────┴─────────────────┴─────────┴──────────┴────────┘ (Low-ef points ef16/32 omitted from the verdict — even at 100 repeats their std is ~8–20%, too noisy; ef128/256 std is ~3–5%.) Verdict: no search-QPS regression - Pure HNSW (16, 32): QPS at parity — within ±5%, and actually slightly faster deterministic at the high-recall points (ef128/256), with equal-or-better recall. - HNSW32,SQ8: more scatter (±10%, mixed direction) — but it tracks small correlated recall differences (det ef256 is 0.961 vs 0.963), i.e. the two different graphs sit at slightly different recall/QPS operating points, not a systematic slowdown. Search code is identical, so this is graph-structure + noise, not a code regression. If you want it pinned down, a recall-matched (interpolated) comparison would remove the operating-point confound. - Bonus: the deterministic build was 2–3× faster in every case (e.g. HNSW32: 277 s vs 527 s; HNSW16: 164 s vs 429 s) — consistent with all prior results. ## Single-threaded (OMP_NUM_THREADS=1) Customers frequently build with OMP=1 or OpenMP disabled, so this case matters. Measured at 1M / d=128 / M=32 / efC=64, single-threaded: build time: lock-based 188.36s vs deterministic 176.40s (0.94x -> deterministic ~6% FASTER) peak RSS: 1.6 GB (both, identical) recall@10 ef 16/32/64/128: lock-based .8830/.9387/.9676/.9853 vs deterministic .8832/.9381/.9625/.9798 No single-threaded regression: the deterministic build is slightly faster (it avoids the per-node OpenMP lock ops), uses the same memory, and matches recall within noise. Note the lock-based build was already deterministic at a single thread, so single-threaded users lose nothing and gain a small speedup. ## Serialization compatibility No on-disk format change, verified in `index_read.cpp` / `index_write.cpp`: - `deterministic_build` was never serialized (zero references), so removing it is format-neutral. It was a runtime build flag, like `retain_locks`. - `write_HNSW` / `read_HNSW` and the `IndexHNSW` field layout are unchanged. The subtype fourcc tags, header, CAGRA block, graph CSR (entry_point / max_level / levels / offsets / neighbors / efC / efS), and storage are all as before. - `keep_max_size_level0` is still serialized only for the CAGRA subtype (`IHc2`/`IHNc`); `init_level0` is build-only (not serialized). - The deterministic build emits the same HNSW CSR structure (only neighbor content differs), so old indexes read unchanged and new indexes remain readable by older Faiss. - Verified by the `io_and_retest` serialize -> deserialize -> re-search round-trips in `test_graph_based.py` / `test_hnsw.cpp` (all pass). ## CAGRA API for HNSW build on multi-GPU (aka D106837134) — MAST verification Verified end-to-end on MAST (8x H100 Grand Teton, Approach D, 100M vectors) with this change in the build — the multi-GPU CAGRA -> HNSW graph-build time is comparable to the D106837134 baseline (no regression): all_neighbors build: 367.4s optimize: 231.7s copyTo: 18.4s serialize: 28.9s (66 GB) INDEX build -> serialize total: 661.7s (11.0 min) [D106837134 baseline: 721s] recall@10 (tiled 100M): ef64 0.7746, ef128 0.8830, ef256 0.9429 - This confirms this CPU-side change builds, links, and runs in the GPU CAGRA binary at scale and does not regress the pipeline. Note the Approach-D run uses copyTo(base_level_only=True), which imports the CAGRA graph directly as HNSW level 0 and skips add(), so it does not itself route through the deterministic add(). - The deterministic CAGRA level-0 import this change adds (the copyTo path with base_level_only=False: init_level0=false skips the level-0 bucket; keep_max_size_level0 fills the base layer) is covered by passing unit tests: `Test_IndexHNSWCagra_BaseLevelOnly_RangeSearch` (C++), `test_hnsw_no_init_level0`, and `test_hnsw_cagra_IP` / `_base_level_only` (Python). ## Behavioral note: level-0 base layer under keep_max_size_level0 (reviewers, please note) One deliberate difference from the removed lock-based build, in the CAGRA base-layer case only: the old build gated the "fill the level-0 list up to 2*M" behavior on the inserted point's OWN top level (`keep_max_size_level0 && pt_level == 0`), so a level>=1 node's level-0 list could be pruned below 2*M. The deterministic build gates on the LINK level (`keep_max_size_level0 && level == 0`), so EVERY node's level-0 list is filled to 2*M when `keep_max_size_level0` is set (not only the level-0-only points). This is a strict superset of the old coverage -- it fills exactly to the 2*M slot capacity (no overflow) and yields a fuller/denser base layer for CPU `IndexHNSWCagra`, which is what `GpuIndexCagra::copyFrom(IndexHNSWCagra*)` reads back. It is INERT for the default build (`keep_max_size_level0` defaults to false, so the gate is never true) and never affects a non-CAGRA graph. Called out explicitly so reviewers know the CPU `IndexHNSWCagra` base-layer graph is intentionally denser than the pre-diff build; worth a sanity check against GPU `copyFrom` expectations. Differential Revision: D112025877
1 parent 4d74915 commit 3dd2f60

6 files changed

Lines changed: 838 additions & 88 deletions

File tree

faiss/IndexHNSW.cpp

Lines changed: 239 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,14 @@ DistanceComputer* storage_distance_computer(const Index* storage) {
6060
return storage->get_distance_computer();
6161
}
6262

63-
void hnsw_add_vertices(
63+
// The HNSW graph build: deterministic and lock-free. Points are bucketed by
64+
// level and shuffled, then each bucket is inserted in prefix-doubling batches.
65+
// Per batch, phase A computes forward links against the frozen snapshot and
66+
// records reciprocal edges; phase B groups those by destination and merges them
67+
// one node at a time in a fixed order. Reproducible across runs at a fixed
68+
// thread count. Supports the CAGRA level-0 import path (init_level0=false skips
69+
// level 0; keep_max_size_level0 fills the base layer to 2*M).
70+
void hnsw_add_vertices_deterministic(
6471
IndexHNSW& index_hnsw,
6572
size_t n0,
6673
size_t n,
@@ -72,7 +79,7 @@ void hnsw_add_vertices(
7279
size_t ntotal = n0 + n;
7380
double t0 = getmillisecs();
7481
if (verbose) {
75-
printf("hnsw_add_vertices: adding %zd elements on top of %zd "
82+
printf("hnsw_add_vertices_deterministic: adding %zd elements on top of %zd "
7683
"(preset_levels=%d)\n",
7784
n,
7885
n0,
@@ -83,16 +90,20 @@ void hnsw_add_vertices(
8390
return;
8491
}
8592

86-
int max_level = hnsw.prepare_level_tab(n, preset_levels);
93+
// Lowest level *bucket* to insert. With init_level0=false (the CAGRA
94+
// level-0 import path) the level-0-only points are skipped, exactly as the
95+
// default build's `pt_level >= !init_level0` bound; points that live on
96+
// higher levels are still inserted and still build their own level-0 links.
97+
const int min_bucket_level = index_hnsw.init_level0 ? 0 : 1;
98+
const bool keep_max_size_level0 = index_hnsw.keep_max_size_level0;
8799

100+
int max_level = hnsw.prepare_level_tab(n, preset_levels);
88101
if (verbose) {
89102
printf(" max_level = %d\n", max_level);
90103
}
91104

92-
auto& locks = index_hnsw.locks;
93-
locks.prepare(ntotal);
94-
95-
// add vectors from highest to lowest level
105+
// Bucket the new points by level (highest first), exactly as
106+
// hnsw_add_vertices does.
96107
std::vector<int> hist;
97108
std::vector<int> order(n);
98109

@@ -122,94 +133,260 @@ void hnsw_add_vertices(
122133
}
123134
}
124135

125-
idx_t check_period = InterruptCallback::get_period_hint(
136+
// Upper bound on batch size (ParlayANN's theta), 2% of the index.
137+
const size_t theta =
138+
std::max<size_t>(1, static_cast<size_t>(0.02 * ntotal));
139+
140+
// Poll the interrupt callback roughly every check_period points inside the
141+
// parallel forward-link phase (phase A), mirroring the default build. A
142+
// single prefix-doubling batch can be up to theta = 2% of ntotal points, so
143+
// without this the build would only honor cancellation at the batch
144+
// boundary -- on very large indexes that can be minutes.
145+
const idx_t check_period = InterruptCallback::get_period_hint(
126146
max_level * index_hnsw.d * hnsw.efConstruction);
127147

128-
{ // perform add
129-
RandomGenerator rng2(789);
148+
RandomGenerator rng2(789);
149+
size_t i1 = n;
150+
151+
for (int pt_level = static_cast<int>(hist.size()) - 1;
152+
pt_level >= min_bucket_level;
153+
pt_level--) {
154+
size_t i0 = i1 - hist[pt_level];
155+
if (i0 == i1) {
156+
continue;
157+
}
130158

131-
size_t i1 = static_cast<int>(n);
159+
if (verbose) {
160+
printf("Adding %zu elements at level %d\n", i1 - i0, pt_level);
161+
}
132162

133-
for (int pt_level = static_cast<int>(hist.size()) - 1;
134-
pt_level >= int(!index_hnsw.init_level0);
135-
pt_level--) {
136-
size_t i0 = i1 - hist[pt_level];
163+
// random permutation to get rid of dataset order bias
164+
for (size_t j = i0; j < i1; j++) {
165+
std::swap(
166+
order[j],
167+
order[j + rng2.rand_int(static_cast<int>(i1 - j))]);
168+
}
137169

138-
if (verbose) {
139-
printf("Adding %zu elements at level %d\n", i1 - i0, pt_level);
140-
}
170+
// Deterministically bootstrap / raise the entry point. The top
171+
// bucket is processed first; its first point (in shuffled order) is
172+
// a valid maximum-level entry point.
173+
if (hnsw.entry_point == -1 || pt_level > hnsw.max_level) {
174+
hnsw.max_level = pt_level;
175+
hnsw.entry_point = order[i0];
176+
}
141177

142-
// random permutation to get rid of dataset order bias
143-
for (size_t j = i0; j < i1; j++) {
144-
std::swap(
145-
order[j],
146-
order[j + rng2.rand_int(static_cast<int>(i1 - j))]);
178+
// Prefix-doubling batches within this bucket.
179+
size_t s = i0;
180+
while (s < i1) {
181+
size_t done = s - i0;
182+
size_t grow = std::min(done == 0 ? size_t(1) : done, theta);
183+
size_t e = std::min(i1, s + grow);
184+
185+
// ---- Phase A: compute forward links against the snapshot ----
186+
// Reciprocal edge recorded during phase A: destination `dest` gains
187+
// an incoming link from `src` at level `level`.
188+
struct Edge {
189+
int level;
190+
HNSW::storage_idx_t dest;
191+
HNSW::storage_idx_t src;
192+
};
193+
194+
// Reverse edges are bump-allocated directly into one pre-sized
195+
// buffer during phase A -- there is no per-thread accumulator. The
196+
// buffer is sized to the exact upper bound (the sum of the batch
197+
// points' neighbor-slot counts); each point writes at most that
198+
// many forward links, so the actual fill is <= cap_sum and the
199+
// buffer never reallocates. `edge_counter` hands out packed
200+
// contiguous runs; its (nondeterministic) ordering does not affect
201+
// the graph because phase B re-groups by destination and re-sorts
202+
// each node's incoming set.
203+
size_t cap_sum = 0;
204+
for (int64_t i = s; i < static_cast<int64_t>(e); i++) {
205+
storage_idx_t pid = order[i];
206+
cap_sum += hnsw.offsets[pid + 1] - hnsw.offsets[pid];
147207
}
148-
208+
std::vector<Edge> edges(cap_sum);
209+
std::atomic<size_t> edge_counter{0};
210+
211+
// Set from inside the parallel region on interrupt and thrown after
212+
// it (we cannot throw out of an OpenMP region). The unsynchronized
213+
// write is a benign race, as in the default build: at worst the
214+
// interrupt is noticed one poll late. It never changes the graph --
215+
// an uninterrupted build processes every point identically.
149216
bool interrupt = false;
150217

151-
#pragma omp parallel if (i1 > i0 + 100)
218+
#pragma omp parallel if (e - s > 100)
152219
{
153220
std::unique_ptr<VisitedTable> vt =
154221
VisitedTable::create(ntotal, hnsw.use_visited_hashset);
155-
156222
std::unique_ptr<DistanceComputer> dis(
157223
storage_distance_computer(index_hnsw.storage));
158-
bool do_display = verbose && omp_get_thread_num() == 0;
159-
size_t prev_display = 0;
224+
225+
std::vector<std::pair<HNSW::storage_idx_t, int>> reverse_edges;
226+
227+
// Per-thread poll counter. With schedule(static) each thread
228+
// walks a contiguous chunk, so counting our own iterations
229+
// polls the interrupt callback every check_period points
230+
// regardless of how the chunk aligns with the global index -- a
231+
// global
232+
// `(i - s) % check_period` would leave threads whose chunk
233+
// straddles no multiple of check_period never polling.
160234
size_t counter = 0;
161235

162-
// here we should do schedule(dynamic) but this segfaults for
163-
// some versions of LLVM. The performance impact should not be
164-
// too large when (i1 - i0) / num_threads >> 1
165236
#pragma omp for schedule(static)
166-
for (int64_t i = i0; i < i1; i++) {
167-
storage_idx_t pt_id = order[i];
168-
dis->set_query(x + (pt_id - n0) * d);
169-
170-
// cannot break
237+
for (int64_t i = s; i < static_cast<int64_t>(e); i++) {
171238
if (interrupt) {
172-
continue;
239+
continue; // cannot break out of an OpenMP for loop
173240
}
241+
storage_idx_t pt_id = order[i];
242+
int lvl = hnsw.levels[pt_id] - 1;
243+
dis->set_query(x + (pt_id - n0) * d);
174244

175-
hnsw.add_with_locks(
245+
reverse_edges.clear();
246+
hnsw.compute_forward_links_deterministic(
176247
*dis,
177-
pt_level,
248+
lvl,
178249
pt_id,
179-
locks,
180250
*vt,
181-
index_hnsw.keep_max_size_level0 && (pt_level == 0));
182-
183-
if (do_display && i - i0 > prev_display + 10000) {
184-
prev_display = i - i0;
185-
printf(" %zu / %zu\r", i - i0, i1 - i0);
186-
fflush(stdout);
251+
reverse_edges,
252+
keep_max_size_level0);
253+
254+
// Grab a contiguous run in the buffer with one atomic bump,
255+
// then fill it (packed, no gaps).
256+
size_t off = edge_counter.fetch_add(
257+
reverse_edges.size(), std::memory_order_relaxed);
258+
for (size_t k = 0; k < reverse_edges.size(); k++) {
259+
edges[off + k] = {
260+
reverse_edges[k].second,
261+
reverse_edges[k].first,
262+
pt_id};
187263
}
188-
if (counter % check_period == 0) {
189-
if (InterruptCallback::is_interrupted()) {
190-
interrupt = true;
191-
}
264+
265+
if (counter++ % check_period == 0 &&
266+
InterruptCallback::is_interrupted()) {
267+
interrupt = true;
192268
}
193-
counter++;
194269
}
195270
}
196271
if (interrupt) {
197272
FAISS_THROW_MSG("computation interrupted");
198273
}
199-
i1 = i0;
200-
}
201-
if (index_hnsw.init_level0) {
202-
FAISS_ASSERT(i1 == 0);
203-
} else {
204-
FAISS_ASSERT((i1 - hist[0]) == 0);
274+
275+
// ---- Phase B: deterministic reciprocal-edge merge ----
276+
// Group the edges by (level, dest) via a fixed-size radix partition
277+
// on the low bits of dest, then merge each bucket in parallel. The
278+
// bucket count is a small constant (independent of ntotal and
279+
// thread count), so this stays O(edges) in memory. Every dest maps
280+
// to exactly one bucket, so each (level, dest) group is wholly
281+
// contained in one bucket; the merge is therefore order- and
282+
// thread-count-independent (merge_reverse_links_deterministic
283+
// re-sorts and dedups each node's incoming set internally), which
284+
// keeps the resulting graph byte-identical across builds. The radix
285+
// partition runs in place, so `edges` stays the only edge buffer.
286+
const size_t total = edge_counter.load();
287+
288+
constexpr int kBucketBits = 8;
289+
constexpr uint32_t kNumBuckets = 1u << kBucketBits;
290+
constexpr uint32_t kBucketMask = kNumBuckets - 1;
291+
292+
// bstart[b]..bstart[b+1] delimits bucket b after partitioning.
293+
std::vector<size_t> bstart(kNumBuckets + 1, 0);
294+
for (size_t idx = 0; idx < total; idx++) {
295+
bstart[(static_cast<uint32_t>(edges[idx].dest) & kBucketMask) +
296+
1]++;
297+
}
298+
for (uint32_t b = 0; b < kNumBuckets; b++) {
299+
bstart[b + 1] += bstart[b];
300+
}
301+
302+
// In-place radix partition (cycle permutation): each iteration
303+
// places at least one edge in its final bucket, so this is O(total)
304+
// and needs no auxiliary edge buffer.
305+
{
306+
std::vector<size_t> head(bstart.begin(), bstart.end() - 1);
307+
for (uint32_t b = 0; b < kNumBuckets; b++) {
308+
size_t end_b = bstart[b + 1];
309+
while (head[b] < end_b) {
310+
Edge cur_e = edges[head[b]];
311+
if ((static_cast<uint32_t>(cur_e.dest) & kBucketMask) ==
312+
b) {
313+
head[b]++;
314+
continue;
315+
}
316+
while ((static_cast<uint32_t>(cur_e.dest) &
317+
kBucketMask) != b) {
318+
uint32_t tb = static_cast<uint32_t>(cur_e.dest) &
319+
kBucketMask;
320+
std::swap(cur_e, edges[head[tb]]);
321+
head[tb]++;
322+
}
323+
edges[head[b]] = cur_e;
324+
head[b]++;
325+
}
326+
}
327+
}
328+
Edge* base = edges.data();
329+
330+
#pragma omp parallel if (total > 100)
331+
{
332+
std::unique_ptr<DistanceComputer> dis(
333+
storage_distance_computer(index_hnsw.storage));
334+
// schedule(static), not (dynamic): the LLVM/libomp dynamic
335+
// dispatcher segfaults in some build configs (see the note in
336+
// the pre-existing lock-based build). Buckets are ~uniform in
337+
// size (radix on dest low bits) so static is well balanced, and
338+
// bucket order does not affect the graph.
339+
#pragma omp for schedule(static)
340+
for (int64_t b = 0; b < static_cast<int64_t>(kNumBuckets);
341+
b++) {
342+
Edge* p = base + bstart[b];
343+
size_t m = bstart[b + 1] - bstart[b];
344+
if (m == 0) {
345+
continue;
346+
}
347+
// Order within the bucket by (level, dest); no src
348+
// tiebreak is needed because the merge re-sorts each
349+
// node's incoming set.
350+
std::sort(p, p + m, [](const Edge& a, const Edge& c) {
351+
if (a.level != c.level) {
352+
return a.level < c.level;
353+
}
354+
return a.dest < c.dest;
355+
});
356+
std::vector<HNSW::storage_idx_t> incoming;
357+
size_t g = 0;
358+
while (g < m) {
359+
size_t h = g + 1;
360+
while (h < m && p[h].level == p[g].level &&
361+
p[h].dest == p[g].dest) {
362+
h++;
363+
}
364+
incoming.clear();
365+
incoming.reserve(h - g);
366+
for (size_t kk = g; kk < h; kk++) {
367+
incoming.push_back(p[kk].src);
368+
}
369+
hnsw.merge_reverse_links_deterministic(
370+
*dis,
371+
p[g].dest,
372+
p[g].level,
373+
incoming,
374+
keep_max_size_level0 && (p[g].level == 0));
375+
g = h;
376+
}
377+
}
378+
}
379+
380+
InterruptCallback::check();
381+
s = e;
205382
}
383+
384+
i1 = i0;
206385
}
386+
207387
if (verbose) {
208388
printf("Done in %.3f ms\n", getmillisecs() - t0);
209389
}
210-
if (!index_hnsw.retain_locks) {
211-
locks.clear();
212-
}
213390
}
214391

215392
} // namespace
@@ -384,13 +561,8 @@ void IndexHNSW::add(idx_t n, const float* x) {
384561
storage->add(n, x);
385562
ntotal = storage->ntotal;
386563

387-
hnsw_add_vertices(
388-
*this,
389-
n0,
390-
n,
391-
x,
392-
verbose,
393-
hnsw.levels.size() == static_cast<size_t>(ntotal));
564+
bool preset_levels = hnsw.levels.size() == static_cast<size_t>(ntotal);
565+
hnsw_add_vertices_deterministic(*this, n0, n, x, verbose, preset_levels);
394566
}
395567

396568
void IndexHNSW::reset() {

faiss/IndexHNSW.h

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ struct IndexHNSW : Index {
5353
// See impl/VisitedTable.h.
5454
std::optional<bool> use_visited_hashset;
5555

56-
// Per-node locks for HNSW graph construction.
56+
// Per-node locks, retained for the dormant init_level_0_from_entry_points
57+
// helper and for IndexBinaryHNSW. The primary add() path is lock-free.
5758
LockVector locks;
5859
// locks are freed after each call to add() unless this flag is set.
5960
bool retain_locks = false;

0 commit comments

Comments
 (0)