mirror of
https://github.com/facebookresearch/faiss.git
synced 2026-10-12 02:13:36 +00:00
Summary: Pull Request resolved: https://github.com/facebookresearch/faiss/pull/5670 Follow-up to D121368933 and public PR https://github.com/facebookresearch/faiss/pull/5657. HNSW and RaBitQ search statistics are available to C++ and Python. Faiss collects them by default. Every search updates shared counters. Under high concurrency, these updates are expensive. In the 64-thread relaxed-atomic profile, `HNSWStats::combine_atomic` uses 74.14% of the sampled user cycles. This diff adds one process-wide flag in `faiss/utils/utils.h`: - `set_search_stats_enabled(bool)` - `get_search_stats_enabled()` The flag is a relaxed `std::atomic<bool>`. It is private to `utils.cpp`. The default is enabled, for backward compatibility. Callers that do not read the statistics can disable collection. Then the searches skip the global atomic updates. At the moment, the flag controls `hnsw_stats` and `rabitq_stats`. The name is generic on purpose. The other global stats objects (for example `indexIVF_stats`, `indexPQ_stats` and `indexPanorama_stats`) use plain `+=` on shared counters, so they have the same scaling problem. Later diffs can put these objects under the same flag without a new API. The documentation says that the values of global stats objects are unspecified when the flag is disabled. Thus, more coverage does not change the contract. API design: - Faiss often exposes a plain global for a setting, for example `distance_compute_blas_threshold`. This diff does not do that. The flag is for concurrent searches, and a thread can change it while other threads search. A plain `bool` is then a data race, and TSAN reports it. This stack fixed TSAN races in D121097648. - SWIG cannot wrap `std::atomic<bool>`. Thus, the setter and the getter are the only API. Python calls `faiss.set_search_stats_enabled(False)`. Other changes: - `bench_hnsw_stats` has a `stats` argument. `stats:0` disables collection, and `stats:1` enables it. One binary measures the two modes. - The tests save the flag value and restore it after the test. The C++ tests use `SearchStatsEnabledGuard`. The Python tests use `addCleanup`. Thus, a failed assertion does not change the flag for later tests in the same process. Mode/opt benchmark from V12, five sequential repetitions (mean real-time throughput): | External pthreads | Relaxed atomic stats enabled | Stats disabled | Change | | ---: | ---: | ---: | ---: | | 1 | 2.516 M/s | 2.411 M/s | 0.96x | | 2 | 3.919 M/s | 4.164 M/s | 1.06x | | 4 | 7.494 M/s | 8.260 M/s | 1.10x | | 8 | 12.717 M/s | 14.170 M/s | 1.11x | | 16 | 16.074 M/s | 22.979 M/s | 1.43x | | 32 | 19.929 M/s | 36.841 M/s | 1.85x | | 64 | 22.101 M/s | 66.642 M/s | 3.02x | The 0.96x value at 1 pthread is noise. A longer pinned measurement in the test plan shows no slowdown. At 1 thread, the atomic adds have no contention, so the expected gain is small. The disabled path scales 27.64x from 1 to 64 pthreads. The enabled path scales 8.78x. The disabled path does not scale linearly, because this one-vector search is very small. At 64 threads, the OpenMP runtime uses about 28% of the sampled cycles. Allocator, RTTI and shared-cache costs also contribute. Reviewed By: luciang Differential Revision: D121374405 fbshipit-source-id: a54ee81a68a63df01c93bd5098171658883cd5ab
851 lines
28 KiB
Python
851 lines
28 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; restore it so
|
|
# it does not leak into subsequent tests.
|
|
self.addCleanup(faiss.omp_set_num_threads, faiss.omp_get_max_threads())
|
|
|
|
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_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
|
|
self.addCleanup(
|
|
faiss.set_search_stats_enabled, faiss.get_search_stats_enabled()
|
|
)
|
|
faiss.set_search_stats_enabled(True)
|
|
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)
|