Files
faiss/tests/test_graph_based.py
T
Michael Norris a424dcb809 faiss HNSW: add opt-in deterministic lock-free graph build (faiss::hnsw_deterministic_build) (#5486)
Summary:
Pull Request resolved: https://github.com/facebookresearch/faiss/pull/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
2026-08-10 14:32:47 -07:00

873 lines
29 KiB
Python

# Copyright (c) Meta Platforms, Inc. and affiliates.
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.
"""a few tests for graph-based indices (HNSW, nndescent and NSG)"""
import numpy as np
import unittest
import faiss
from common_faiss_tests import get_dataset_2, for_all_simd_levels
@for_all_simd_levels
class TestHNSW(unittest.TestCase):
def __init__(self, *args, **kwargs):
unittest.TestCase.__init__(self, *args, **kwargs)
d = 32
nt = 0
nb = 1500
nq = 500
(_, self.xb, self.xq) = get_dataset_2(d, nt, nb, nq)
index = faiss.IndexFlatL2(d)
index.add(self.xb)
Dref, Iref = index.search(self.xq, 1)
self.Iref = Iref
def test_hnsw(self):
d = self.xq.shape[1]
index = faiss.IndexHNSWFlat(d, 16)
index.add(self.xb)
Dhnsw, Ihnsw = index.search(self.xq, 1)
self.assertGreaterEqual((self.Iref == Ihnsw).sum(), 460)
self.io_and_retest(index, Dhnsw, Ihnsw)
def test_deterministic_build(self):
# The HNSW build is deterministic and lock-free: repeated builds, and
# builds at different thread counts, produce byte-identical graphs.
# nb must be large enough that the batch-size cap (2% of n) exceeds
# the >100 threshold gating the parallel build region; otherwise the
# build runs single-threaded and determinism is trivially untested.
d = 32
nb = 20000
xb = np.random.RandomState(123).random((nb, d)).astype("float32")
# build() mutates the process-global OpenMP thread count and the
# deterministic-build flag; restore both so they do not leak into
# subsequent tests.
self.addCleanup(faiss.omp_set_num_threads, faiss.omp_get_max_threads())
self.addCleanup(
setattr,
faiss.cvar,
"hnsw_deterministic_build",
faiss.cvar.hnsw_deterministic_build,
)
faiss.cvar.hnsw_deterministic_build = True
def build(nthreads):
faiss.omp_set_num_threads(nthreads)
index = faiss.IndexHNSWFlat(d, 16)
index.add(xb)
return index
# `index.hnsw` returns a fresh (copied) proxy and `.neighbors` /
# `.offsets` are views into it, so chaining `index.hnsw.neighbors`
# inline reads a freed temporary. Bind the hnsw proxy to keep it alive
# across the (copying) vector_to_array calls.
def graph_of(index):
hnsw = index.hnsw
return (
hnsw.max_level,
faiss.vector_to_array(hnsw.neighbors),
faiss.vector_to_array(hnsw.offsets),
)
idx8 = build(8)
max_level, g8a, offs = graph_of(idx8)
# graph is non-trivial: multi-level, many real edges
self.assertGreaterEqual(max_level, 1)
self.assertGreater(int((g8a >= 0).sum()), nb)
# reproducible across repeated builds and across thread counts
g8b = graph_of(build(8))[1]
g1 = graph_of(build(1))[1]
np.testing.assert_array_equal(g8a, g8b)
np.testing.assert_array_equal(g8a, g1)
# no self-loops
for i in range(nb):
seg = g8a[int(offs[i]) : int(offs[i + 1])]
self.assertNotIn(i, seg[seg >= 0].tolist())
def test_deterministic_build_recall(self):
# Same recall bar as test_hnsw (which exercises the lock-based default),
# so the deterministic graph is not merely reproducible but as good.
self.addCleanup(
setattr,
faiss.cvar,
"hnsw_deterministic_build",
faiss.cvar.hnsw_deterministic_build,
)
faiss.cvar.hnsw_deterministic_build = True
index = faiss.IndexHNSWFlat(self.xq.shape[1], 16)
index.add(self.xb)
Dhnsw, Ihnsw = index.search(self.xq, 1)
self.assertGreaterEqual((self.Iref == Ihnsw).sum(), 460)
self.io_and_retest(index, Dhnsw, Ihnsw)
def test_range_search(self):
index_flat = faiss.IndexFlat(self.xb.shape[1])
index_flat.add(self.xb)
D, _ = index_flat.search(self.xq, 10)
radius = np.median(D[:, -1])
lims_ref, Dref, Iref = index_flat.range_search(self.xq, radius)
index = faiss.IndexHNSWFlat(self.xb.shape[1], 16)
index.add(self.xb)
lims, D, I = index.range_search(self.xq, radius)
nmiss = 0
# check if returned results are a subset of the reference results
for i in range(len(self.xq)):
ref = Iref[lims_ref[i] : lims_ref[i + 1]]
new = I[lims[i] : lims[i + 1]]
self.assertLessEqual(set(new), set(ref))
nmiss += len(ref) - len(new)
# currently we miss 405 / 6019 neighbors
self.assertLessEqual(nmiss, lims_ref[-1] * 0.1)
def test_hnsw_unbounded_queue(self):
d = self.xq.shape[1]
index = faiss.IndexHNSWFlat(d, 16)
index.add(self.xb)
index.hnsw.search_bounded_queue = False
Dhnsw, Ihnsw = index.search(self.xq, 1)
self.assertGreaterEqual((self.Iref == Ihnsw).sum(), 460)
self.io_and_retest(index, Dhnsw, Ihnsw)
def test_hnsw_no_init_level0(self):
d = self.xq.shape[1]
index = faiss.IndexHNSWFlat(d, 16)
index.init_level0 = False
index.add(self.xb)
Dhnsw, Ihnsw = index.search(self.xq, 1)
# This is expected to be smaller because we are not initializing
# vectors into level 0.
self.assertGreaterEqual((self.Iref == Ihnsw).sum(), 25)
self.io_and_retest(index, Dhnsw, Ihnsw)
def io_and_retest(self, index, Dhnsw, Ihnsw):
index2 = faiss.deserialize_index(faiss.serialize_index(index))
Dhnsw2, Ihnsw2 = index2.search(self.xq, 1)
self.assertTrue(np.all(Dhnsw2 == Dhnsw))
self.assertTrue(np.all(Ihnsw2 == Ihnsw))
# Verify deserialized index is serializable again
index2b = faiss.deserialize_index(faiss.serialize_index(index2))
Dhnsw2b, Ihnsw2b = index2b.search(self.xq, 1)
self.assertTrue(np.all(Dhnsw2b == Dhnsw))
self.assertTrue(np.all(Ihnsw2b == Ihnsw))
# also test clone
index3 = faiss.clone_index(index)
Dhnsw3, Ihnsw3 = index3.search(self.xq, 1)
self.assertTrue(np.all(Dhnsw3 == Dhnsw))
self.assertTrue(np.all(Ihnsw3 == Ihnsw))
def test_hnsw_2level(self):
d = self.xq.shape[1]
quant = faiss.IndexFlatL2(d)
index = faiss.IndexHNSW2Level(quant, 256, 8, 8)
index.train(self.xb)
index.add(self.xb)
Dhnsw, Ihnsw = index.search(self.xq, 1)
self.assertGreaterEqual((self.Iref == Ihnsw).sum(), 307)
self.io_and_retest(index, Dhnsw, Ihnsw)
def test_hnsw_2level_mixed_search(self):
d = self.xq.shape[1]
quant = faiss.IndexFlatL2(d)
storage = faiss.IndexIVFPQ(quant, d, 32, 8, 8)
storage.make_direct_map()
index = faiss.IndexHNSW2Level(quant, 32, 8, 8)
index.storage = storage
index.train(self.xb)
index.add(self.xb)
Dhnsw, Ihnsw = index.search(self.xq, 1)
# It is expected that the mixed search will perform worse.
self.assertGreaterEqual((self.Iref == Ihnsw).sum(), 200)
self.io_and_retest(index, Dhnsw, Ihnsw)
def test_add_0_vecs(self):
index = faiss.IndexHNSWFlat(10, 16)
zero_vecs = np.zeros((0, 10), dtype="float32")
# infinite loop
index.add(zero_vecs)
self.assertEqual(index.ntotal, 0)
def test_hnsw_IP(self):
d = self.xq.shape[1]
index_IP = faiss.IndexFlatIP(d)
index_IP.add(self.xb)
Dref, Iref = index_IP.search(self.xq, 1)
index = faiss.IndexHNSWFlat(d, 16, faiss.METRIC_INNER_PRODUCT)
index.add(self.xb)
Dhnsw, Ihnsw = index.search(self.xq, 1)
self.assertGreaterEqual((Iref == Ihnsw).sum(), 470)
mask = Iref[:, 0] == Ihnsw[:, 0]
self.assertTrue(np.allclose(Dref[mask, 0], Dhnsw[mask, 0]))
def test_ndis_stats(self):
d = self.xq.shape[1]
index = faiss.IndexHNSWFlat(d, 16)
index.add(self.xb)
stats = faiss.cvar.hnsw_stats
stats.reset()
Dhnsw, Ihnsw = index.search(self.xq, 1)
self.assertGreater(stats.ndis, len(self.xq) * index.hnsw.efSearch)
def test_io_no_storage(self):
d = self.xq.shape[1]
index = faiss.IndexHNSWFlat(d, 16)
index.add(self.xb)
Dref, Iref = index.search(self.xq, 5)
# test writing without storage
index2 = faiss.deserialize_index(
faiss.serialize_index(index, faiss.IO_FLAG_SKIP_STORAGE)
)
self.assertEqual(index2.storage, None)
self.assertRaises(RuntimeError, index2.search, self.xb, 1)
# make sure we can store an index with empty storage
faiss.deserialize_index(faiss.serialize_index(index2))
# add storage afterwards
index.storage = faiss.clone_index(index.storage)
index.own_fields = True
Dnew, Inew = index.search(self.xq, 5)
np.testing.assert_array_equal(Dnew, Dref)
np.testing.assert_array_equal(Inew, Iref)
def test_hnsw_reset(self):
d = self.xb.shape[1]
index_flat = faiss.IndexFlat(d)
index_flat.add(self.xb)
self.assertEqual(index_flat.ntotal, self.xb.shape[0])
index_hnsw = faiss.IndexHNSW(index_flat)
index_hnsw.add(self.xb)
# * 2 because we add to storage twice. This is just for testing
# that storage gets cleared correctly.
self.assertEqual(index_hnsw.ntotal, self.xb.shape[0] * 2)
index_hnsw.reset()
self.assertEqual(index_flat.ntotal, 0)
self.assertEqual(index_hnsw.ntotal, 0)
class TestHNSWSimilarity(unittest.TestCase):
"""Coverage for HNSW variants built with a similarity metric
(METRIC_INNER_PRODUCT). These tests exercise the
HNSW::is_similarity dispatch in IndexHNSW{Flat,PQ,SQ,Cagra}: build,
search, and (for IndexHNSWFlat) save/load round trips that have to
re-derive `hnsw.is_similarity` from the persisted metric type.
"""
def setUp(self):
d = 32
nt = 1500
nb = 1500
nq = 200
(self.xt, self.xb, self.xq) = get_dataset_2(d, nt, nb, nq)
# Reference IP search results.
ref = faiss.IndexFlatIP(d)
ref.add(self.xb)
self.Dref_ip, self.Iref_ip = ref.search(self.xq, 1)
def test_hnsw_flat_IP_is_similarity_set(self):
d = self.xb.shape[1]
index = faiss.IndexHNSWFlat(d, 16, faiss.METRIC_INNER_PRODUCT)
self.assertTrue(index.hnsw.is_similarity)
index_l2 = faiss.IndexHNSWFlat(d, 16, faiss.METRIC_L2)
self.assertFalse(index_l2.hnsw.is_similarity)
def _check_quantized_IP(self, index, min_agreement):
"""Build (train if needed), search, then save / load and assert
that the round trip preserves ordering and `hnsw.is_similarity`.
The agreement floors are calibrated against the observed values
on the test dataset with a small (~5%) headroom for RNG noise;
they catch ordering regressions (which collapse agreement to
~0) and quantizer-quality regressions (which would shift
agreement by tens of points)."""
self.assertTrue(index.hnsw.is_similarity)
if not index.is_trained:
index.train(self.xt)
index.add(self.xb)
Dhnsw, Ihnsw = index.search(self.xq, 1)
agreement = (self.Iref_ip[:, 0] == Ihnsw[:, 0]).sum()
self.assertGreaterEqual(
agreement,
min_agreement,
f"{type(index).__name__} agreement={agreement} < {min_agreement}",
)
index2 = faiss.deserialize_index(faiss.serialize_index(index))
# `hnsw.is_similarity` is not serialized; index_read must
# re-derive it from the persisted metric_type.
self.assertTrue(index2.hnsw.is_similarity)
self.assertEqual(index2.metric_type, faiss.METRIC_INNER_PRODUCT)
D2, I2 = index2.search(self.xq, 1)
np.testing.assert_array_equal(I2, Ihnsw)
np.testing.assert_array_equal(D2, Dhnsw)
# Agreement floors below are calibrated against measured values
# (Flat: 199, PQ: 26-30, SQ: 195, Cagra: 200 over nq=200) with a
# small headroom for RNG noise.
def test_hnsw_flat_IP(self):
d = self.xb.shape[1]
self._check_quantized_IP(
faiss.IndexHNSWFlat(d, 16, faiss.METRIC_INNER_PRODUCT),
# observed ~199/200; headroom for OpenMP-parallel-build
# nondeterminism
min_agreement=180,
)
def test_hnsw_pq_IP(self):
d = self.xb.shape[1]
self._check_quantized_IP(
faiss.IndexHNSWPQ(d, 8, 16, 8, faiss.METRIC_INNER_PRODUCT),
min_agreement=20,
)
def test_hnsw_sq_IP(self):
d = self.xb.shape[1]
self._check_quantized_IP(
faiss.IndexHNSWSQ(
d,
faiss.ScalarQuantizer.QT_8bit,
16,
faiss.METRIC_INNER_PRODUCT,
),
min_agreement=190,
)
def test_hnsw_cagra_IP(self):
d = self.xb.shape[1]
self._check_quantized_IP(
faiss.IndexHNSWCagra(d, 16, faiss.METRIC_INNER_PRODUCT),
min_agreement=195,
)
def test_hnsw_cagra_IP_base_level_only(self):
"""IndexHNSWCagra.base_level_only takes a separate code path
(random entry-point selection + search_level_0) that also
needs to dispatch on is_similarity."""
d = self.xb.shape[1]
index = faiss.IndexHNSWCagra(d, 16, faiss.METRIC_INNER_PRODUCT)
index.add(self.xb)
index.base_level_only = True
index.num_base_level_search_entrypoints = 64
_, Ihnsw = index.search(self.xq, 1)
agreement = (self.Iref_ip[:, 0] == Ihnsw[:, 0]).sum()
# Measured value on this dataset is 200/200; leave headroom for
# the RNG-driven entry-point selection (this path is unseeded).
self.assertGreaterEqual(
agreement,
190,
f"Cagra base_only agreement={agreement}",
)
class TestHNSWNaN(unittest.TestCase):
"""Adding a vector with NaN to an IVF+HNSW index used to crash because
NaN distances corrupt the MinimaxHeap ordering in HNSW search. The fix
converts NaN to +inf in MinimaxHeap::push so the heap stays well-ordered.
"""
def test_add_nan_vector_to_ivf_hnsw(self):
d = 64
nt = 2000
nb = 1000
xt = np.random.default_rng(42).random((nt, d), dtype="float32")
xb = np.random.default_rng(43).random((nb, d), dtype="float32")
index = faiss.index_factory(d, "IVF256_HNSW32,SQ8")
index.train(xt)
index.add(xb)
# Create a vector with NaN in the first component
vec = np.zeros((1, d), dtype="float32")
vec[0, 0] = np.nan
# This should not crash
index.add(vec)
self.assertEqual(index.ntotal, nb + 1)
class Issue3684(unittest.TestCase):
def test_issue3684(self):
np.random.seed(1234) # For reproducibility
d = 256 # Example dimension
nb = 10 # Number of database vectors
nq = 2 # Number of query vectors
xb = np.random.random((nb, d)).astype("float32")
xq = np.random.random((nq, d)).astype("float32")
faiss.normalize_L2(xb) # Normalize both query and database vectors
faiss.normalize_L2(xq)
hnsw_index_ip = faiss.IndexHNSWFlat(256, 16, faiss.METRIC_INNER_PRODUCT)
hnsw_index_ip.hnsw.efConstruction = 512
hnsw_index_ip.hnsw.efSearch = 512
hnsw_index_ip.add(xb)
# test knn
D, I = hnsw_index_ip.search(xq, 10)
self.assertTrue(np.all(D[:, :-1] >= D[:, 1:]))
# test range search
radius = 0.74 # Cosine similarity threshold
lims, D, I = hnsw_index_ip.range_search(xq, radius)
self.assertTrue(np.all(D >= radius))
class TestNSG(unittest.TestCase):
def __init__(self, *args, **kwargs):
unittest.TestCase.__init__(self, *args, **kwargs)
d = 32
nt = 0
nb = 1500
nq = 500
self.GK = 32
_, self.xb, self.xq = get_dataset_2(d, nt, nb, nq)
def make_knn_graph(self, metric):
n = self.xb.shape[0]
d = self.xb.shape[1]
index = faiss.IndexFlat(d, metric)
index.add(self.xb)
_, I = index.search(self.xb, self.GK + 1)
knn_graph = np.zeros((n, self.GK), dtype=np.int64)
# For the inner product distance, the distance between a vector and
# itself may not be the smallest, so it is not guaranteed that I[:, 0]
# is the query itself.
for i in range(n):
cnt = 0
for j in range(self.GK + 1):
if I[i, j] != i:
knn_graph[i, cnt] = I[i, j]
cnt += 1
if cnt == self.GK:
break
return knn_graph
def subtest_io_and_clone(self, index, Dnsg, Insg):
index2 = faiss.deserialize_index(faiss.serialize_index(index))
Dnsg2, Insg2 = index2.search(self.xq, 1)
np.testing.assert_array_equal(Dnsg2, Dnsg)
np.testing.assert_array_equal(Insg2, Insg)
# Verify deserialized index is serializable again
index2b = faiss.deserialize_index(faiss.serialize_index(index2))
Dnsg2b, Insg2b = index2b.search(self.xq, 1)
np.testing.assert_array_equal(Dnsg2b, Dnsg)
np.testing.assert_array_equal(Insg2b, Insg)
# also test clone
index3 = faiss.clone_index(index)
Dnsg3, Insg3 = index3.search(self.xq, 1)
np.testing.assert_array_equal(Dnsg3, Dnsg)
np.testing.assert_array_equal(Insg3, Insg)
def subtest_connectivity(self, index, nb):
vt = faiss.VisitedTableVector(nb)
count = index.nsg.dfs(vt, index.nsg.enterpoint, 0)
self.assertEqual(count, nb)
def subtest_add(self, build_type, thresh, metric=faiss.METRIC_L2):
d = self.xq.shape[1]
flat_index = faiss.IndexFlat(d, metric)
flat_index.add(self.xb)
Dref, Iref = flat_index.search(self.xq, 1)
index = faiss.IndexNSGFlat(d, 16, metric)
index.verbose = True
index.build_type = build_type
index.GK = self.GK
index.add(self.xb)
Dnsg, Insg = index.search(self.xq, 1)
recalls = (Iref == Insg).sum()
self.assertGreaterEqual(recalls, thresh)
self.subtest_connectivity(index, self.xb.shape[0])
self.subtest_io_and_clone(index, Dnsg, Insg)
def subtest_build(self, knn_graph, thresh, metric=faiss.METRIC_L2):
d = self.xq.shape[1]
flat_index = faiss.IndexFlat(d, metric)
flat_index.add(self.xb)
Dref, Iref = flat_index.search(self.xq, 1)
index = faiss.IndexNSGFlat(d, 16, metric)
index.verbose = True
index.build(self.xb, knn_graph)
Dnsg, Insg = index.search(self.xq, 1)
recalls = (Iref == Insg).sum()
self.assertGreaterEqual(recalls, thresh)
self.subtest_connectivity(index, self.xb.shape[0])
def test_add_bruteforce_L2(self):
self.subtest_add(0, 475, faiss.METRIC_L2)
def test_add_nndescent_L2(self):
self.subtest_add(1, 475, faiss.METRIC_L2)
def test_add_bruteforce_IP(self):
self.subtest_add(0, 480, faiss.METRIC_INNER_PRODUCT)
def test_add_nndescent_IP(self):
self.subtest_add(1, 480, faiss.METRIC_INNER_PRODUCT)
def test_build_L2(self):
knn_graph = self.make_knn_graph(faiss.METRIC_L2)
self.subtest_build(knn_graph, 475, faiss.METRIC_L2)
def test_build_IP(self):
knn_graph = self.make_knn_graph(faiss.METRIC_INNER_PRODUCT)
self.subtest_build(knn_graph, 480, faiss.METRIC_INNER_PRODUCT)
def test_build_invalid_knng(self):
"""Make some invalid entries in the input knn graph.
It would cause a warning but IndexNSG should be able
to handle this.
"""
knn_graph = self.make_knn_graph(faiss.METRIC_L2)
knn_graph[:100, 5] = -111
self.subtest_build(knn_graph, 475, faiss.METRIC_L2)
knn_graph = self.make_knn_graph(faiss.METRIC_INNER_PRODUCT)
knn_graph[:100, 5] = -111
self.subtest_build(knn_graph, 480, faiss.METRIC_INNER_PRODUCT)
def test_reset(self):
"""test IndexNSG.reset()"""
d = self.xq.shape[1]
metric = faiss.METRIC_L2
flat_index = faiss.IndexFlat(d, metric)
flat_index.add(self.xb)
Dref, Iref = flat_index.search(self.xq, 1)
index = faiss.IndexNSGFlat(d, 16)
index.verbose = True
index.GK = 32
index.add(self.xb)
Dnsg, Insg = index.search(self.xq, 1)
recalls = (Iref == Insg).sum()
self.assertGreaterEqual(recalls, 475)
self.subtest_connectivity(index, self.xb.shape[0])
index.reset()
index.add(self.xb)
Dnsg, Insg = index.search(self.xq, 1)
recalls = (Iref == Insg).sum()
self.assertGreaterEqual(recalls, 475)
self.subtest_connectivity(index, self.xb.shape[0])
def test_order(self):
"""make sure that output results are sorted"""
d = self.xq.shape[1]
index = faiss.IndexNSGFlat(d, 32)
index.train(self.xb)
index.add(self.xb)
k = 10
nq = self.xq.shape[0]
D, _ = index.search(self.xq, k)
indices = np.argsort(D, axis=1)
gt = np.arange(0, k)[np.newaxis, :] # [1, k]
gt = np.repeat(gt, nq, axis=0) # [nq, k]
np.testing.assert_array_equal(indices, gt)
def test_nsg_supports_pre_built_knn_graph(self):
"""Test IndexNSGBuild"""
knn_graph = self.make_knn_graph(faiss.METRIC_L2)
d = self.xq.shape[1]
index = faiss.IndexNSGFlat(d, 16)
index.build(self.xb, knn_graph)
index.search(self.xq, k=1)
self.assertTrue(index.is_built)
def test_nsg_with_pre_built_knn_graph_throws_when_rebuilding_via_add(self):
"""Test IndexNSGBuild"""
knn_graph = self.make_knn_graph(faiss.METRIC_L2)
d = self.xq.shape[1]
index = faiss.IndexNSGFlat(d, 16)
index.build(self.xb, knn_graph)
index.search(self.xq, k=1)
self.assertTrue(index.is_built)
index.GK = 32
index.train(self.xb)
with self.assertRaises(RuntimeError) as context:
index.add(self.xb)
self.assertIn(
"NSG does not support incremental addition", str(context.exception)
)
def test_nsg_rebuild_throws_with_pre_built_knn_graph(self):
"""Test IndexNSGBuild"""
knn_graph = self.make_knn_graph(faiss.METRIC_L2)
d = self.xq.shape[1]
index = faiss.IndexNSGFlat(d, 16)
index.build(self.xb, knn_graph)
index.search(self.xq, k=1)
with self.assertRaises(RuntimeError) as context:
index.build(self.xb, knn_graph)
self.assertIn("The IndexNSG is already built", str(context.exception))
def test_nsg_pq(self):
"""Test IndexNSGPQ"""
d = self.xq.shape[1]
R, pq_M = 32, 4
index = faiss.index_factory(d, f"NSG{R}_PQ{pq_M}np")
self.assertIsInstance(index, faiss.IndexNSGPQ)
idxpq = faiss.downcast_index(index.storage)
self.assertEqual(index.nsg.R, R)
self.assertEqual(idxpq.pq.M, pq_M)
flat_index = faiss.IndexFlat(d)
flat_index.add(self.xb)
Dref, Iref = flat_index.search(self.xq, k=1)
index.GK = 32
index.train(self.xb)
index.add(self.xb)
D, I = index.search(self.xq, k=1)
# test accuracy
recalls = (Iref == I).sum()
# observed ~193/500; PQ4 is coarse -- headroom for OpenMP
# FP-reduction / tie-break nondeterminism in NSG build + PQ assign
self.assertGreaterEqual(recalls, 180)
# test I/O
self.subtest_io_and_clone(index, D, I)
def test_nsg_sq(self):
"""Test IndexNSGSQ"""
d = self.xq.shape[1]
R = 32
index = faiss.index_factory(d, f"NSG{R}_SQ8")
self.assertIsInstance(index, faiss.IndexNSGSQ)
idxsq = faiss.downcast_index(index.storage)
self.assertEqual(index.nsg.R, R)
self.assertEqual(idxsq.sq.qtype, faiss.ScalarQuantizer.QT_8bit)
flat_index = faiss.IndexFlat(d)
flat_index.add(self.xb)
Dref, Iref = flat_index.search(self.xq, k=1)
index.train(self.xb)
index.add(self.xb)
D, I = index.search(self.xq, k=1)
# test accuracy
recalls = (Iref == I).sum()
# nominal ~411/500; loosened for OpenMP graph-build nondeterminism
self.assertGreaterEqual(recalls, 395)
# test I/O
self.subtest_io_and_clone(index, D, I)
class TestNNDescent(unittest.TestCase):
def __init__(self, *args, **kwargs):
unittest.TestCase.__init__(self, *args, **kwargs)
d = 32
nt = 0
nb = 1500
nq = 500
self.GK = 32
_, self.xb, self.xq = get_dataset_2(d, nt, nb, nq)
def test_nndescentflat(self):
d = self.xq.shape[1]
index = faiss.IndexNNDescentFlat(d, 32)
index.nndescent.search_L = 8
flat_index = faiss.IndexFlat(d)
flat_index.add(self.xb)
Dref, Iref = flat_index.search(self.xq, k=1)
index.train(self.xb)
index.add(self.xb)
D, I = index.search(self.xq, k=1)
# test accuracy
recalls = (Iref == I).sum()
self.assertGreaterEqual(recalls, 450) # 462
# do some IO tests
index2 = faiss.deserialize_index(faiss.serialize_index(index))
D2, I2 = index2.search(self.xq, 1)
np.testing.assert_array_equal(D2, D)
np.testing.assert_array_equal(I2, I)
# Verify deserialized index is serializable again
index2b = faiss.deserialize_index(faiss.serialize_index(index2))
D2b, I2b = index2b.search(self.xq, 1)
np.testing.assert_array_equal(D2b, D)
np.testing.assert_array_equal(I2b, I)
# also test clone
index3 = faiss.clone_index(index)
D3, I3 = index3.search(self.xq, 1)
np.testing.assert_array_equal(D3, D)
np.testing.assert_array_equal(I3, I)
def test_order(self):
"""make sure that output results are sorted"""
d = self.xq.shape[1]
index = faiss.IndexNNDescentFlat(d, 32)
index.train(self.xb)
index.add(self.xb)
k = 10
nq = self.xq.shape[0]
D, _ = index.search(self.xq, k)
indices = np.argsort(D, axis=1)
gt = np.arange(0, k)[np.newaxis, :] # [1, k]
gt = np.repeat(gt, nq, axis=0) # [nq, k]
np.testing.assert_array_equal(indices, gt)
class TestNNDescentGenRandom(unittest.TestCase):
"""Regression tests for gen_random edge cases in NNDescent."""
def test_search_L_equals_ntotal(self):
"""gen_random(size, N) crashed with division by zero when size == N.
In search(), L_2 = max(search_L, topk). When search_L >= ntotal,
gen_random is called with size == N, causing rng() % 0.
"""
d = 32
nb = 200 # just above NUM_EVAL_POINTS=100
xb = np.random.default_rng(42).random((nb, d)).astype("float32")
xq = np.random.default_rng(43).random((10, d)).astype("float32")
index = faiss.IndexNNDescentFlat(d, 32)
index.nndescent.search_L = nb # triggers gen_random(size=nb, N=nb)
index.train(xb)
index.add(xb)
# This crashed with division by zero before the fix
D, I = index.search(xq, k=1)
self.assertEqual(I.shape, (xq.shape[0], 1))
self.assertEqual(D.shape, (xq.shape[0], 1))
class TestNNDescentKNNG(unittest.TestCase):
def test_knng_L2(self):
self.subtest(32, 10, faiss.METRIC_L2)
def test_knng_IP(self):
self.subtest(32, 10, faiss.METRIC_INNER_PRODUCT)
def subtest(self, d, K, metric):
nb = 1000
_, xb, _ = get_dataset_2(d, 0, nb, 0)
_, knn = faiss.knn(xb, xb, K + 1, metric)
knn = knn[:, 1:]
index = faiss.IndexNNDescentFlat(d, K, metric)
index.nndescent.S = 10
index.nndescent.R = 32
index.nndescent.L = K + 20
index.nndescent.iter = 5
index.verbose = True
index.add(xb)
graph = index.nndescent.final_graph
graph = faiss.vector_to_array(graph)
graph = graph.reshape(nb, K)
recalls = 0
for i in range(nb):
for j in range(K):
for k in range(K):
if graph[i, j] == knn[i, k]:
recalls += 1
break
recall = 1.0 * recalls / (nb * K)
self.assertGreater(recall, 0.99)
def test_small_nndescent(self):
"""building a too small graph used to crash, make sure it raises
an exception instead.
TODO: build the exact knn graph for small cases
"""
d = 32
K = 10
index = faiss.IndexNNDescentFlat(d, K, faiss.METRIC_L2)
index.nndescent.S = 10
index.nndescent.R = 32
index.nndescent.L = K + 20
index.nndescent.iter = 5
index.verbose = True
xb = np.zeros((78, d), dtype="float32")
self.assertRaises(RuntimeError, index.add, xb)