Skip to content

Commit a424dcb

Browse files
Michael Norrismeta-codesync[bot]
authored andcommitted
faiss HNSW: add opt-in deterministic lock-free graph build (faiss::hnsw_deterministic_build) (facebookresearch#5486)
Summary: Pull Request resolved: facebookresearch#5486 TLDR: adds deterministic HNSW build inspired by ParlayANN. The deterministic build is reproducible AND faster than the lock-based build at every scale and thread count we measured. This is gated behind `faiss::hnsw_deterministic_build`. We plan to test internally, then make this the default flow and remove the existing flow. -- https://arxiv.org/abs/2305.04359 -- HOW TO ENABLE -- ``` C++: faiss::hnsw_deterministic_build = true; Python: faiss.cvar.hnsw_deterministic_build = True ``` Nothing else is needed, and nothing changes until you do it: the flag defaults to false, so this diff is a no-op on its own. An embedder driving this from its own configuration system should resolve the value once at startup and assign the global, rather than per index: changing it between two `add()` calls would leave one index built by each algorithm. `python/__init__.pyi` now declares `faiss.cvar`. It was missing, so any Python consumer assigning a faiss global failed Pyre with "No attribute `cvar` in module `faiss`" -- fixed here rather than with a `pyre-ignore` in each consumer. WHEN IT IS WORTH ENABLING -- Only for builds that run multi-threaded. At one thread the existing lock-based build is *already* deterministic, so the flag buys nothing beyond ~6% build time. Verified: lock-based at OMP=1 is byte-identical across runs, while at OMP=8 46% of neighbor slots differ. A consumer that pins `omp_set_num_threads(1)` should not bother. 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: - re-uses Faiss HNSW pruning in `shrink_neighbor_list` --- AI (with a bunch of edits) explanation in more detail: -- What changed - `IndexHNSW::add` and `IndexBinaryHNSW::add` pick the build at runtime. Both paths are compiled in and **the default is unchanged** (lock-based); nothing switches until the flag or the hook says so. - Turning it on: ``` C++: faiss::hnsw_deterministic_build = true; Python: faiss.cvar.hnsw_deterministic_build = True ``` - A single plain `bool` is the whole API: libfaiss takes no dependency on any configuration system, and an embedder that wants one resolves it itself and assigns the global: ``` faiss.cvar.hnsw_deterministic_build = <your config lookup> ``` - Resolve it once at startup, not per index. `add()` reads the flag each call, so changing it between two `add()` calls on one index would build part of the graph with each algorithm -- reproducible in neither. - **This flag is transitional.** Once the deterministic build is validated in production, the follow-up makes it the only path and deletes the lock-based build and the flag. - 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. - `IndexBinaryHNSW::add` is gated by the same flag and shares the same deterministic implementation: `hnsw_add_vertices_deterministic` takes `make_distance_computer` / `set_query` callbacks, so the binary index just supplies its own Hamming distance computer. It keeps its own lock-based `hnsw_add_vertices` for the default path. 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 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`: - `hnsw_deterministic_build` is a namespace-scope global, not a field on `HNSW` or `IndexHNSW`, so it is not part of any serialized struct. Like `retain_locks`, it is a pure runtime build flag. - `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). - Measured on a 1M index: written with the flag off, flag flipped on, read back -> `neighbors`, `offsets`, `entry_point`, `max_level` byte-identical and search ids+distances identical. The flag is read only inside `add()`. - Mixed-mode append (lock-built graph extended by a deterministic `add()`) was measured against brute-force ground truth at 220k and is indistinguishable from either pure build -- recall@10 within +-0.02 of both at ef 32/64/128. No existing test covers this, since every test builds one way in one process. ## 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 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. Reviewed By: pankajsingh88 Differential Revision: D112025877 fbshipit-source-id: dd111a5a3674c08200905bfea1d056f0fa5b3d42
1 parent 2688c34 commit a424dcb

7 files changed

Lines changed: 986 additions & 33 deletions

File tree

faiss/IndexBinaryHNSW.cpp

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -270,13 +270,25 @@ void IndexBinaryHNSW::add(idx_t n, const uint8_t* x) {
270270
storage->add(n, x);
271271
ntotal = storage->ntotal;
272272

273-
hnsw_add_vertices(
274-
*this,
275-
n0,
276-
n,
277-
x,
278-
verbose,
279-
hnsw.levels.size() == static_cast<size_t>(ntotal));
273+
bool preset_levels = hnsw.levels.size() == static_cast<size_t>(ntotal);
274+
275+
if (hnsw_deterministic_build) {
276+
hnsw_add_vertices_deterministic(
277+
hnsw,
278+
n0,
279+
n,
280+
d,
281+
init_level0,
282+
keep_max_size_level0,
283+
preset_levels,
284+
verbose,
285+
[this] { return get_distance_computer(); },
286+
[this, x, n0](DistanceComputer& dc, HNSW::storage_idx_t pt_id) {
287+
dc.set_query((const float*)(x + (pt_id - n0) * code_size));
288+
});
289+
} else {
290+
hnsw_add_vertices(*this, n0, n, x, verbose, preset_levels);
291+
}
280292
}
281293

282294
void IndexBinaryHNSW::reset() {

faiss/IndexHNSW.cpp

Lines changed: 317 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ using NodeDistFarther = HNSW::NodeDistFarther;
4141

4242
HNSWStats hnsw_stats;
4343

44+
bool hnsw_deterministic_build = false;
45+
4446
/**************************************************************
4547
* add / search blocks of descriptors
4648
**************************************************************/
@@ -214,6 +216,302 @@ void hnsw_add_vertices(
214216

215217
} // namespace
216218

219+
// Deterministic HNSW graph build inspired by ParlayANN: points are bucketed by
220+
// level and inserted in prefix-doubling batches. Phase A computes forward
221+
// links against a frozen snapshot and records reverse edges; phase B merges
222+
// those in a fixed order.
223+
// https://arxiv.org/abs/2305.04359
224+
void hnsw_add_vertices_deterministic(
225+
HNSW& hnsw,
226+
size_t n0,
227+
size_t n,
228+
int d,
229+
bool init_level0,
230+
bool keep_max_size_level0,
231+
bool preset_levels,
232+
bool verbose,
233+
const std::function<DistanceComputer*()>& make_distance_computer,
234+
const std::function<void(DistanceComputer&, HNSW::storage_idx_t)>&
235+
set_query) {
236+
size_t ntotal = n0 + n;
237+
double t0 = getmillisecs();
238+
if (verbose) {
239+
printf("hnsw_add_vertices_deterministic: adding %zd elements on top of %zd "
240+
"(preset_levels=%d)\n",
241+
n,
242+
n0,
243+
int(preset_levels));
244+
}
245+
246+
if (n == 0) {
247+
return;
248+
}
249+
250+
// init_level0=false (CAGRA import) skips the level-0-only bucket; higher-
251+
// level points still build their own level-0 links.
252+
const int min_bucket_level = init_level0 ? 0 : 1;
253+
254+
int max_level = hnsw.prepare_level_tab(n, preset_levels);
255+
if (verbose) {
256+
printf(" max_level = %d\n", max_level);
257+
}
258+
259+
// Bucket the new points by level, highest first.
260+
std::vector<int> hist;
261+
std::vector<int> order(n);
262+
263+
{ // make buckets with vectors of the same level
264+
265+
// build histogram
266+
for (size_t i = 0; i < n; i++) {
267+
storage_idx_t pt_id = static_cast<storage_idx_t>(i + n0);
268+
int pt_level = hnsw.levels[pt_id] - 1;
269+
while (pt_level >= static_cast<int>(hist.size())) {
270+
hist.push_back(0);
271+
}
272+
hist[pt_level]++;
273+
}
274+
275+
// accumulate
276+
std::vector<int> offsets(hist.size() + 1, 0);
277+
for (size_t i = 0; i < hist.size() - 1; i++) {
278+
offsets[i + 1] = offsets[i] + hist[i];
279+
}
280+
281+
// bucket sort
282+
for (size_t i = 0; i < n; i++) {
283+
storage_idx_t pt_id = static_cast<storage_idx_t>(i + n0);
284+
int pt_level = hnsw.levels[pt_id] - 1;
285+
order[offsets[pt_level]++] = pt_id;
286+
}
287+
}
288+
289+
// Upper bound on batch size (ParlayANN's theta), 2% of the index.
290+
const size_t theta =
291+
std::max<size_t>(1, static_cast<size_t>(0.02 * ntotal));
292+
293+
// Polled inside phase A: a batch can be 2% of ntotal, so cancelling only
294+
// at batch boundaries would take minutes on large indexes.
295+
const idx_t check_period = InterruptCallback::get_period_hint(
296+
max_level * d * hnsw.efConstruction);
297+
298+
RandomGenerator rng2(789);
299+
size_t i1 = n;
300+
301+
for (int pt_level = static_cast<int>(hist.size()) - 1;
302+
pt_level >= min_bucket_level;
303+
pt_level--) {
304+
size_t i0 = i1 - hist[pt_level];
305+
if (i0 == i1) {
306+
continue;
307+
}
308+
309+
if (verbose) {
310+
printf("Adding %zu elements at level %d\n", i1 - i0, pt_level);
311+
}
312+
313+
// random permutation to get rid of dataset order bias
314+
for (size_t j = i0; j < i1; j++) {
315+
std::swap(
316+
order[j],
317+
order[j + rng2.rand_int(static_cast<int>(i1 - j))]);
318+
}
319+
320+
// Bootstrap/raise the entry point: the top bucket runs first, so its
321+
// first (shuffled) point is a valid max-level entry point.
322+
if (hnsw.entry_point == -1 || pt_level > hnsw.max_level) {
323+
hnsw.max_level = pt_level;
324+
hnsw.entry_point = order[i0];
325+
}
326+
327+
// Prefix-doubling batches within this bucket.
328+
size_t s = i0;
329+
while (s < i1) {
330+
size_t done = s - i0;
331+
size_t grow = std::min(done == 0 ? size_t(1) : done, theta);
332+
size_t e = std::min(i1, s + grow);
333+
334+
// Phase A: compute forward links against the snapshot
335+
// A reverse edge: `dest` gains an incoming link from `src`.
336+
struct Edge {
337+
int level;
338+
HNSW::storage_idx_t dest;
339+
HNSW::storage_idx_t src;
340+
};
341+
342+
// One buffer, sized to an upper bound so it never reallocates.
343+
// The fill order is nondeterministic but harmless: phase B
344+
// regroups by destination.
345+
size_t cap_sum = 0;
346+
for (int64_t i = s; i < static_cast<int64_t>(e); i++) {
347+
storage_idx_t pid = order[i];
348+
cap_sum += hnsw.offsets[pid + 1] - hnsw.offsets[pid];
349+
}
350+
// Default-initialised to skip a memset per sub-batch; safe because
351+
// edge_counter only hands out slots that are written before read.
352+
std::unique_ptr<Edge[]> reverse_edges(new Edge[cap_sum]);
353+
std::atomic<size_t> edge_counter{0};
354+
355+
// Thrown after the region (cannot throw out of one); the
356+
// unsynchronized write costs at most one extra poll.
357+
bool interrupt = false;
358+
359+
#pragma omp parallel if (e - s > 100)
360+
{
361+
std::unique_ptr<VisitedTable> vt =
362+
VisitedTable::create(ntotal, hnsw.use_visited_hashset);
363+
364+
std::unique_ptr<DistanceComputer> dis(make_distance_computer());
365+
std::vector<std::pair<HNSW::storage_idx_t, int>>
366+
pt_reverse_edges;
367+
size_t counter = 0;
368+
369+
#pragma omp for schedule(static)
370+
for (int64_t i = s; i < static_cast<int64_t>(e); i++) {
371+
if (interrupt) {
372+
continue; // cannot break out of an OpenMP for loop
373+
}
374+
storage_idx_t pt_id = order[i];
375+
int lvl = hnsw.levels[pt_id] - 1;
376+
set_query(*dis, pt_id);
377+
378+
pt_reverse_edges.clear();
379+
hnsw.compute_forward_links_deterministic(
380+
*dis,
381+
lvl,
382+
pt_id,
383+
*vt,
384+
pt_reverse_edges,
385+
keep_max_size_level0);
386+
387+
size_t off = edge_counter.fetch_add(
388+
pt_reverse_edges.size(), std::memory_order_relaxed);
389+
for (size_t k = 0; k < pt_reverse_edges.size(); k++) {
390+
reverse_edges[off + k] = {
391+
pt_reverse_edges[k].second,
392+
pt_reverse_edges[k].first,
393+
pt_id};
394+
}
395+
396+
if (counter++ % check_period == 0 &&
397+
InterruptCallback::is_interrupted()) {
398+
interrupt = true;
399+
}
400+
}
401+
}
402+
if (interrupt) {
403+
FAISS_THROW_MSG("computation interrupted");
404+
}
405+
406+
// Phase B: merge the reverse edges
407+
// Group by destination so each node is merged by exactly one
408+
// thread: lock-free and thread-count-independent.
409+
const size_t total = edge_counter.load();
410+
411+
constexpr int kBucketBits = 8;
412+
constexpr uint32_t kNumBuckets = 1u << kBucketBits;
413+
constexpr uint32_t kBucketMask = kNumBuckets - 1;
414+
415+
// bstart[b]..bstart[b+1] delimits bucket b after partitioning.
416+
std::vector<size_t> bstart(kNumBuckets + 1, 0);
417+
for (size_t idx = 0; idx < total; idx++) {
418+
bstart[(static_cast<uint32_t>(reverse_edges[idx].dest) &
419+
kBucketMask) +
420+
1]++;
421+
}
422+
for (uint32_t b = 0; b < kNumBuckets; b++) {
423+
bstart[b + 1] += bstart[b];
424+
}
425+
426+
// In-place partition (cycle sort): each step lands at least one
427+
// edge in its bucket, so O(total) with no auxiliary buffer.
428+
{
429+
std::vector<size_t> head(bstart.begin(), bstart.end() - 1);
430+
for (uint32_t b = 0; b < kNumBuckets; b++) {
431+
size_t end_b = bstart[b + 1];
432+
while (head[b] < end_b) {
433+
Edge cur_e = reverse_edges[head[b]];
434+
if ((static_cast<uint32_t>(cur_e.dest) & kBucketMask) ==
435+
b) {
436+
head[b]++;
437+
continue;
438+
}
439+
while ((static_cast<uint32_t>(cur_e.dest) &
440+
kBucketMask) != b) {
441+
uint32_t tb = static_cast<uint32_t>(cur_e.dest) &
442+
kBucketMask;
443+
std::swap(cur_e, reverse_edges[head[tb]]);
444+
head[tb]++;
445+
}
446+
reverse_edges[head[b]] = cur_e;
447+
head[b]++;
448+
}
449+
}
450+
}
451+
Edge* base = reverse_edges.get();
452+
453+
#pragma omp parallel if (total > 100)
454+
{
455+
std::unique_ptr<DistanceComputer> dis(make_distance_computer());
456+
// Not schedule(dynamic): the libomp dynamic dispatcher
457+
// segfaults in some build configs.
458+
#pragma omp for schedule(static)
459+
for (int64_t b = 0; b < static_cast<int64_t>(kNumBuckets);
460+
b++) {
461+
Edge* p = base + bstart[b];
462+
size_t m = bstart[b + 1] - bstart[b];
463+
if (m == 0) {
464+
continue;
465+
}
466+
// src order does not matter; the merge re-sorts each set.
467+
std::sort(p, p + m, [](const Edge& a, const Edge& c) {
468+
if (a.level != c.level) {
469+
return a.level < c.level;
470+
}
471+
return a.dest < c.dest;
472+
});
473+
std::vector<HNSW::storage_idx_t> incoming;
474+
size_t g = 0;
475+
while (g < m) {
476+
size_t h = g + 1;
477+
while (h < m && p[h].level == p[g].level &&
478+
p[h].dest == p[g].dest) {
479+
h++;
480+
}
481+
incoming.clear();
482+
incoming.reserve(h - g);
483+
for (size_t kk = g; kk < h; kk++) {
484+
incoming.push_back(p[kk].src);
485+
}
486+
hnsw.merge_reverse_links_deterministic(
487+
*dis,
488+
p[g].dest,
489+
p[g].level,
490+
incoming,
491+
keep_max_size_level0 && (p[g].level == 0));
492+
g = h;
493+
}
494+
}
495+
}
496+
497+
InterruptCallback::check();
498+
s = e;
499+
}
500+
501+
i1 = i0;
502+
}
503+
504+
if (init_level0) {
505+
FAISS_ASSERT(i1 == 0);
506+
} else {
507+
FAISS_ASSERT((i1 - hist[0]) == 0);
508+
}
509+
510+
if (verbose) {
511+
printf("Done in %.3f ms\n", getmillisecs() - t0);
512+
}
513+
}
514+
217515
/**************************************************************
218516
* IndexHNSW implementation
219517
**************************************************************/
@@ -384,13 +682,25 @@ void IndexHNSW::add(idx_t n, const float* x) {
384682
storage->add(n, x);
385683
ntotal = storage->ntotal;
386684

387-
hnsw_add_vertices(
388-
*this,
389-
n0,
390-
n,
391-
x,
392-
verbose,
393-
hnsw.levels.size() == static_cast<size_t>(ntotal));
685+
bool preset_levels = hnsw.levels.size() == static_cast<size_t>(ntotal);
686+
687+
if (hnsw_deterministic_build) {
688+
hnsw_add_vertices_deterministic(
689+
hnsw,
690+
n0,
691+
n,
692+
d,
693+
init_level0,
694+
keep_max_size_level0,
695+
preset_levels,
696+
verbose,
697+
[this] { return storage_distance_computer(storage); },
698+
[this, x, n0](DistanceComputer& dc, HNSW::storage_idx_t pt_id) {
699+
dc.set_query(x + (pt_id - n0) * d);
700+
});
701+
} else {
702+
hnsw_add_vertices(*this, n0, n, x, verbose, preset_levels);
703+
}
394704
}
395705

396706
void IndexHNSW::reset() {

0 commit comments

Comments
 (0)