mirror of
https://github.com/facebookresearch/faiss.git
synced 2026-10-11 22:50:00 +00:00
Summary: Pull Request resolved: https://github.com/facebookresearch/faiss/pull/5620 **TL;DR:** Eight SIMD kernel tests never ran, and two byte-domain kernels trust their caller. This change makes three fixes: 1. **Six tests never compiled.** `test_rabitq_simd.cpp` hides them behind `COMPILE_SIMD_*` macros that the faiss target does not export. 2. **Two tests skipped themselves in a static build.** `TestSQByteDirectAcrossSIMDLevels` needs `SIMDLevel::NONE` as a reference, and a static build does not report that level. 3. **The byte kernels never test their dimension requirement.** `DistanceComputerByte` and `DistanceComputerByteSigned` read 16 bytes per loop step. ## 1. Six tests never compiled `test_rabitq_simd.cpp` wraps two blocks in `#ifdef COMPILE_SIMD_AVX2` and `#ifdef COMPILE_SIMD_AVX512`. The faiss target sets those macros as `preprocessor_flags`, and it does not export them. A test translation unit never sees them. So the preprocessor deleted both blocks. The file holds nine tests and ran three. The AVX2 block loses its guard, because every x86 faiss build holds the AVX2 kernels. Its three tests also lose an `#ifdef FAISS_ENABLE_DD` around a runtime skip, which the same problem disabled. `is_simd_level_available()` reports the right answer in a static build too. The AVX512 block moves to a dynamic dispatch target. A static x86 build compiles the AVX2 kernels only, so the test fails to link there rather than skips. `test_rabitq_simd_vpopcnt.cpp` becomes `test_rabitq_simd_dd.cpp`, because it now holds every RaBitQ test that a static build cannot link. A new `test_rabitq_simd_util.h` holds the scalar reference that both files call. ## 2. Two tests skipped themselves in a static build `TestSQByteDirectAcrossSIMDLevels` measured the index a second time at `SIMDLevel::NONE`, then compared the two runs. A static build reports only the level it compiles, and omits `NONE`, so both tests skipped. The tests now compute the distances from the definition. The distances are integers, so the reference is exact. The tests check absolute values now, instead of the agreement of two faiss paths. ## 3. The byte kernels trust the caller `DistanceComputerByte` and `DistanceComputerByteSigned` read 16 bytes per loop step, so they require `d % 16 == 0`. `sq-dispatch.h` enforces that, and no other caller builds them. A caller that ignores the requirement reads up to 15 bytes past each code and returns a wrong distance. Each constructor now tests the requirement and throws. The AVX2 and the ARM_NEON specializations both get the test. Reviewed By: alibeklfc Differential Revision: D119545379 fbshipit-source-id: ddfdcc30d3662f469b2ec3dbb9d5b9f4dafa21aa
684 lines
25 KiB
Python
684 lines
25 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.
|
|
|
|
from __future__ import absolute_import, division, print_function
|
|
|
|
import os
|
|
import tempfile
|
|
|
|
import numpy as np
|
|
|
|
import faiss
|
|
import unittest
|
|
|
|
from faiss.contrib.datasets import SyntheticDataset
|
|
|
|
from common_faiss_tests import for_all_simd_levels, NoneSIMDLevel
|
|
|
|
|
|
@for_all_simd_levels
|
|
class TestScalarQuantizerEncodeDecode(unittest.TestCase):
|
|
|
|
def setUp(self):
|
|
self.d = 32
|
|
self.ds = SyntheticDataset(d=self.d, nt=0, nb=1000, nq=0, seed=42)
|
|
self.xb = self.ds.get_database()
|
|
|
|
def do_encode_decode(self, qtype, max_err):
|
|
sq = faiss.ScalarQuantizer(self.d, qtype)
|
|
sq.train(self.xb)
|
|
codes = sq.compute_codes(self.xb)
|
|
xb_decoded = sq.decode(codes)
|
|
self.assertEqual(xb_decoded.shape, self.xb.shape)
|
|
err = np.abs(self.xb - xb_decoded).max()
|
|
self.assertLess(err, max_err)
|
|
|
|
def test_4bit(self):
|
|
self.do_encode_decode(faiss.ScalarQuantizer.QT_4bit, 0.1)
|
|
|
|
def test_fp16(self):
|
|
self.do_encode_decode(faiss.ScalarQuantizer.QT_fp16, 0.001)
|
|
|
|
def test_8bit_uniform(self):
|
|
self.do_encode_decode(faiss.ScalarQuantizer.QT_8bit_uniform, 0.01)
|
|
|
|
def test_4bit_uniform(self):
|
|
self.do_encode_decode(faiss.ScalarQuantizer.QT_4bit_uniform, 0.1)
|
|
|
|
def test_tqmse_1bit(self):
|
|
faiss.normalize_L2(self.xb)
|
|
self.do_encode_decode(faiss.ScalarQuantizer.QT_1bit_tqmse, 1.0)
|
|
|
|
def test_tqmse_2bit(self):
|
|
faiss.normalize_L2(self.xb)
|
|
self.do_encode_decode(faiss.ScalarQuantizer.QT_2bit_tqmse, 0.8)
|
|
|
|
def test_tqmse_3bit(self):
|
|
faiss.normalize_L2(self.xb)
|
|
self.do_encode_decode(faiss.ScalarQuantizer.QT_3bit_tqmse, 0.6)
|
|
|
|
def test_tqmse_4bit(self):
|
|
faiss.normalize_L2(self.xb)
|
|
self.do_encode_decode(faiss.ScalarQuantizer.QT_4bit_tqmse, 0.2)
|
|
|
|
def test_tqmse_8bit(self):
|
|
faiss.normalize_L2(self.xb)
|
|
self.do_encode_decode(faiss.ScalarQuantizer.QT_8bit_tqmse, 0.1)
|
|
|
|
def test_tqmse_preserves_norm(self):
|
|
"""tqmse encodes unit-norm vectors with no per-vector scale factor in
|
|
the code, so reconstructions must keep unit norm. The Lloyd-Max table
|
|
is optimal for unit *variance*, so it only lines up with unit-norm
|
|
input once scaled by 1/sqrt(d) -- without that, every component lands
|
|
in the innermost cells and the codebook collapses toward a sign
|
|
quantizer, inflating norms by 15% at d=64 and 258% at d=768."""
|
|
for qtype, nbits in (
|
|
(faiss.ScalarQuantizer.QT_1bit_tqmse, 1),
|
|
(faiss.ScalarQuantizer.QT_2bit_tqmse, 2),
|
|
(faiss.ScalarQuantizer.QT_3bit_tqmse, 3),
|
|
(faiss.ScalarQuantizer.QT_4bit_tqmse, 4),
|
|
(faiss.ScalarQuantizer.QT_8bit_tqmse, 8),
|
|
):
|
|
for d in (32, 64, 768):
|
|
with self.subTest(nbits=nbits, d=d):
|
|
rs = np.random.RandomState(123)
|
|
x = rs.randn(200, d).astype("float32")
|
|
faiss.normalize_L2(x)
|
|
|
|
sq = faiss.ScalarQuantizer(d, qtype)
|
|
sq.train(x)
|
|
decoded = sq.decode(sq.compute_codes(x))
|
|
norms = np.linalg.norm(decoded, axis=1)
|
|
|
|
# 1-bit cannot resolve magnitude at all (every component
|
|
# reconstructs to +/-c), so it gets a looser band.
|
|
tol = 0.25 if nbits == 1 else 0.1
|
|
self.assertLess(abs(np.median(norms) - 1.0), tol)
|
|
|
|
# Guard the collapse directly: a codebook matched to the
|
|
# data reconstructs most of its 2^nbits distinct levels.
|
|
# This is what catches 8-bit, whose norm stays near 1 even
|
|
# when collapsed (31 of 256 levels used at d=768).
|
|
used = len(np.unique(decoded))
|
|
self.assertGreaterEqual(used, 2**nbits // 2)
|
|
|
|
def test_codes_match_none(self):
|
|
"""SQ codes are integer; encode dispatch (sq-dispatch.h) must
|
|
produce bit-identical output at every SIMD level. Catches drift in
|
|
the per-ISA scalar-quantizer encode kernels."""
|
|
if not faiss.SIMDConfig.is_simd_level_available(faiss.SIMDLevel_NONE):
|
|
self.skipTest("SIMDLevel.NONE not available")
|
|
for qtype in (
|
|
faiss.ScalarQuantizer.QT_8bit,
|
|
faiss.ScalarQuantizer.QT_4bit,
|
|
faiss.ScalarQuantizer.QT_8bit_uniform,
|
|
faiss.ScalarQuantizer.QT_4bit_uniform,
|
|
faiss.ScalarQuantizer.QT_fp16,
|
|
):
|
|
with self.subTest(qtype=qtype):
|
|
sq = faiss.ScalarQuantizer(self.d, qtype)
|
|
sq.train(self.xb)
|
|
codes = sq.compute_codes(self.xb)
|
|
with NoneSIMDLevel():
|
|
codes_none = sq.compute_codes(self.xb)
|
|
np.testing.assert_array_equal(codes, codes_none)
|
|
|
|
|
|
@for_all_simd_levels
|
|
class TestScalarQuantizerSearch(unittest.TestCase):
|
|
|
|
def setUp(self):
|
|
self.d = 32
|
|
self.ds = SyntheticDataset(d=self.d, nt=0, nb=10000, nq=100, seed=42)
|
|
self.xb = self.ds.get_database()
|
|
self.xq = self.ds.get_queries()
|
|
self.xb_unit = self.xb.copy()
|
|
self.xq_unit = self.xq.copy()
|
|
faiss.normalize_L2(self.xb_unit)
|
|
faiss.normalize_L2(self.xq_unit)
|
|
|
|
def do_search(self, factory_str, min_recall):
|
|
index_gt = faiss.IndexFlatL2(self.d)
|
|
index_gt.add(self.xb)
|
|
_, I_gt = index_gt.search(self.xq, 10)
|
|
|
|
index = faiss.index_factory(self.d, factory_str)
|
|
index.train(self.xb)
|
|
index.add(self.xb)
|
|
_, I = index.search(self.xq, 10)
|
|
|
|
recall = (I_gt[:, 0] == I[:, 0]).mean()
|
|
self.assertGreater(recall, min_recall)
|
|
|
|
def test_SQ4(self):
|
|
self.do_search("SQ4", 0.5)
|
|
|
|
def test_SQfp16(self):
|
|
self.do_search("SQfp16", 0.99)
|
|
|
|
def test_tqmse_search(self):
|
|
index_gt = faiss.IndexFlatL2(self.d)
|
|
index_gt.add(self.xb_unit)
|
|
_, I_gt = index_gt.search(self.xq_unit, 10)
|
|
|
|
recalls = {}
|
|
for factory_str in ["L2norm,RR,SQtqmse4", "L2norm,RR,SQtqmse8"]:
|
|
index = faiss.index_factory(self.d, factory_str)
|
|
index.train(self.xb)
|
|
index.add(self.xb)
|
|
_, I = index.search(self.xq, 10)
|
|
recalls[factory_str] = (I_gt[:, 0] == I[:, 0]).mean()
|
|
|
|
self.assertGreater(recalls["L2norm,RR,SQtqmse4"], 0.2)
|
|
self.assertGreaterEqual(
|
|
recalls["L2norm,RR,SQtqmse8"], recalls["L2norm,RR,SQtqmse4"]
|
|
)
|
|
|
|
|
|
@for_all_simd_levels
|
|
class TestScalarQuantizerDistances(unittest.TestCase):
|
|
|
|
def test_distance_matches_reconstruct(self):
|
|
d = 32
|
|
ds = SyntheticDataset(d=d, nt=0, nb=100, nq=1, seed=42)
|
|
x = ds.get_queries()
|
|
xb = ds.get_database()
|
|
|
|
index = faiss.index_factory(d, "SQ8")
|
|
index.train(xb)
|
|
index.add(xb)
|
|
|
|
D, I = index.search(x, 10)
|
|
|
|
for i in range(10):
|
|
xr = index.reconstruct(int(I[0, i]))
|
|
dist = ((x[0] - xr) ** 2).sum()
|
|
self.assertAlmostEqual(D[0, i], dist, places=4)
|
|
|
|
def test_tqmse_distance_matches_reconstruct(self):
|
|
d = 32
|
|
ds = SyntheticDataset(d=d, nt=0, nb=100, nq=1, seed=42)
|
|
x = ds.get_queries()
|
|
xb = ds.get_database()
|
|
faiss.normalize_L2(x)
|
|
faiss.normalize_L2(xb)
|
|
|
|
index = faiss.index_factory(d, "SQtqmse8")
|
|
index.train(xb)
|
|
index.add(xb)
|
|
|
|
D, I = index.search(x, 10)
|
|
|
|
for i in range(10):
|
|
xr = index.reconstruct(int(I[0, i]))
|
|
dist = ((x[0] - xr) ** 2).sum()
|
|
self.assertAlmostEqual(D[0, i], dist, places=4)
|
|
|
|
|
|
@for_all_simd_levels
|
|
class TestScalarQuantizerEdgeCases(unittest.TestCase):
|
|
|
|
def test_zero_vectors(self):
|
|
d = 32
|
|
xb = np.zeros((100, d), dtype="float32")
|
|
index = faiss.index_factory(d, "SQ8")
|
|
index.train(xb)
|
|
index.add(xb)
|
|
D, _ = index.search(np.zeros((1, d), dtype="float32"), 10)
|
|
self.assertTrue(np.allclose(D, 0, atol=1e-5))
|
|
|
|
def test_constant_vectors(self):
|
|
d = 32
|
|
xb = np.ones((100, d), dtype="float32") * 0.5
|
|
index = faiss.index_factory(d, "SQ8")
|
|
index.train(xb)
|
|
index.add(xb)
|
|
D, _ = index.search(np.ones((1, d), dtype="float32") * 0.5, 10)
|
|
self.assertTrue(np.allclose(D, 0, atol=1e-3))
|
|
|
|
def test_extreme_dims(self):
|
|
"""Test extreme dimension values (very small and very large)."""
|
|
for d in [1, 1024]:
|
|
with self.subTest(d=d):
|
|
ds = SyntheticDataset(d=d, nt=0, nb=100, nq=10, seed=42)
|
|
xb = ds.get_database()
|
|
xq = ds.get_queries()
|
|
index = faiss.index_factory(d, "SQ8")
|
|
index.train(xb)
|
|
index.add(xb)
|
|
D, I = index.search(xq, 10)
|
|
self.assertEqual(D.shape, (10, 10))
|
|
self.assertEqual(I.shape, (10, 10))
|
|
|
|
def test_non_simd_dims(self):
|
|
"""Test dimensions not aligned to SIMD width (8/16)."""
|
|
for d in [7, 9, 15, 17, 31, 33, 63, 65]:
|
|
with self.subTest(d=d):
|
|
ds = SyntheticDataset(d=d, nt=0, nb=100, nq=5, seed=42)
|
|
xb = ds.get_database()
|
|
xq = ds.get_queries()
|
|
index = faiss.index_factory(d, "SQ8")
|
|
index.train(xb)
|
|
index.add(xb)
|
|
D, I = index.search(xq, 10)
|
|
self.assertEqual(D.shape, (5, 10))
|
|
self.assertEqual(I.shape, (5, 10))
|
|
|
|
def test_tqmse_non_simd_dims(self):
|
|
factory_strings = [
|
|
"SQtqmse1",
|
|
"SQtqmse2",
|
|
"SQtqmse3",
|
|
"SQtqmse4",
|
|
"SQtqmse8",
|
|
]
|
|
for d in [7, 9, 15, 17, 31, 33, 63, 65]:
|
|
for factory_str in factory_strings:
|
|
with self.subTest(d=d, factory_str=factory_str):
|
|
ds = SyntheticDataset(d=d, nt=0, nb=100, nq=5, seed=42)
|
|
xb = ds.get_database()
|
|
xq = ds.get_queries()
|
|
faiss.normalize_L2(xb)
|
|
faiss.normalize_L2(xq)
|
|
index = faiss.index_factory(d, factory_str)
|
|
index.train(xb)
|
|
index.add(xb)
|
|
D, I = index.search(xq, 10)
|
|
self.assertEqual(D.shape, (5, 10))
|
|
self.assertEqual(I.shape, (5, 10))
|
|
|
|
|
|
@for_all_simd_levels
|
|
class TestScalarQuantizerIP(unittest.TestCase):
|
|
|
|
def test_inner_product(self):
|
|
d = 32
|
|
ds = SyntheticDataset(d=d, nt=0, nb=1000, nq=10, seed=42)
|
|
xb = ds.get_database().copy()
|
|
xq = ds.get_queries().copy()
|
|
faiss.normalize_L2(xb)
|
|
faiss.normalize_L2(xq)
|
|
|
|
index = faiss.index_factory(d, "SQ8", faiss.METRIC_INNER_PRODUCT)
|
|
index.train(xb)
|
|
index.add(xb)
|
|
D, _ = index.search(xq, 10)
|
|
|
|
# normalized vectors: max IP should be close to 1
|
|
self.assertTrue(np.all(D[:, 0] <= 1.1))
|
|
self.assertTrue(np.all(D[:, 0] >= 0.5))
|
|
|
|
|
|
class TestTurboQUnbiasedness(unittest.TestCase):
|
|
"""Verify TurboQuant reconstruction preserves inner products."""
|
|
|
|
def test_reconstruction_quality(self):
|
|
rng = np.random.RandomState(42)
|
|
print("\n=== TurboQuant reconstruction quality ===")
|
|
print(f"{'d':>6} {'qt':>8} {'self_ip':>10} {'cos_sim':>10}")
|
|
print("-" * 42)
|
|
|
|
for d in [128, 256, 384, 512, 768]:
|
|
n = 500
|
|
xb = rng.randn(n, d).astype("float32")
|
|
faiss.normalize_L2(xb)
|
|
|
|
for qt_name, qt in [
|
|
("SQtq2", faiss.ScalarQuantizer.QT_2bit_tq),
|
|
("SQtq3", faiss.ScalarQuantizer.QT_3bit_tq),
|
|
]:
|
|
index = faiss.IndexScalarQuantizer(d, qt)
|
|
index.train(xb)
|
|
index.add(xb)
|
|
|
|
xr = np.vstack([index.reconstruct(i) for i in range(n)])
|
|
|
|
# Self inner product: <x, x_hat> for each vector
|
|
self_ip = np.array([xb[i] @ xr[i] for i in range(n)])
|
|
mean_self_ip = self_ip.mean()
|
|
|
|
# Cosine similarity
|
|
xr_norms = np.linalg.norm(xr, axis=1)
|
|
cos_sim = np.array(
|
|
[xb[i] @ xr[i] / (xr_norms[i] + 1e-30) for i in range(n)]
|
|
).mean()
|
|
|
|
print(
|
|
f"{d:>6} {qt_name:>8} "
|
|
f"{mean_self_ip:>10.4f} {cos_sim:>10.4f}"
|
|
)
|
|
|
|
# Reconstruction should point in the right direction
|
|
self.assertGreater(
|
|
mean_self_ip,
|
|
0.1,
|
|
msg=f"d={d} {qt_name}: self IP {mean_self_ip:.4f} too low",
|
|
)
|
|
|
|
|
|
class TestTurboQFullFactory(unittest.TestCase):
|
|
"""Test factory string construction for TurboQ full types."""
|
|
|
|
def test_factory_sqtq(self):
|
|
cases = [
|
|
("SQtq2", faiss.ScalarQuantizer.QT_2bit_tq),
|
|
("SQtq3", faiss.ScalarQuantizer.QT_3bit_tq),
|
|
("SQtq4", faiss.ScalarQuantizer.QT_4bit_tq),
|
|
("SQtq5", faiss.ScalarQuantizer.QT_5bit_tq),
|
|
]
|
|
for factory_str, qtype in cases:
|
|
with self.subTest(factory_str=factory_str):
|
|
index = faiss.index_factory(32, factory_str)
|
|
self.assertIsInstance(index, faiss.IndexScalarQuantizer)
|
|
self.assertEqual(index.sq.qtype, qtype)
|
|
|
|
def test_factory_ivf_sqtq(self):
|
|
d = 64
|
|
for sq_str in ["SQtq2", "SQtq3", "SQtq4"]:
|
|
with self.subTest(sq_str=sq_str):
|
|
factory_str = f"IVF16,{sq_str}"
|
|
index = faiss.index_factory(d, factory_str)
|
|
self.assertIsInstance(index, faiss.IndexIVFScalarQuantizer)
|
|
ivf = faiss.downcast_index(index)
|
|
self.assertFalse(ivf.by_residual)
|
|
|
|
|
|
class TestTurboQFullEncodeDecode(unittest.TestCase):
|
|
"""Encode/decode roundtrip for TurboQ full types."""
|
|
|
|
def test_encode_decode_roundtrip(self):
|
|
for d in [32, 64, 128]:
|
|
for qtype, max_err in [
|
|
(faiss.ScalarQuantizer.QT_2bit_tq, 1.5),
|
|
(faiss.ScalarQuantizer.QT_3bit_tq, 1.2),
|
|
(faiss.ScalarQuantizer.QT_4bit_tq, 0.8),
|
|
]:
|
|
with self.subTest(d=d, qtype=qtype):
|
|
rng = np.random.RandomState(42)
|
|
xb = rng.randn(200, d).astype("float32")
|
|
faiss.normalize_L2(xb)
|
|
sq = faiss.ScalarQuantizer(d, qtype)
|
|
sq.train(xb)
|
|
codes = sq.compute_codes(xb)
|
|
xr = sq.decode(codes)
|
|
self.assertEqual(xr.shape, xb.shape)
|
|
err = np.abs(xb - xr).max()
|
|
self.assertLess(err, max_err)
|
|
|
|
def test_non_simd_aligned_dims(self):
|
|
for d in [7, 9, 15, 17, 33, 65]:
|
|
with self.subTest(d=d):
|
|
rng = np.random.RandomState(42)
|
|
xb = rng.randn(100, d).astype("float32")
|
|
faiss.normalize_L2(xb)
|
|
sq = faiss.ScalarQuantizer(d, faiss.ScalarQuantizer.QT_2bit_tq)
|
|
sq.train(xb)
|
|
codes = sq.compute_codes(xb)
|
|
xr = sq.decode(codes)
|
|
self.assertEqual(xr.shape, xb.shape)
|
|
|
|
|
|
class TestTurboQFullSearch(unittest.TestCase):
|
|
"""Search recall tests for TurboQ full types."""
|
|
|
|
def setUp(self):
|
|
self.d = 64
|
|
self.rng = np.random.RandomState(42)
|
|
self.nb = 5000
|
|
self.nq = 50
|
|
self.xb = self.rng.randn(self.nb, self.d).astype("float32")
|
|
self.xq = self.rng.randn(self.nq, self.d).astype("float32")
|
|
faiss.normalize_L2(self.xb)
|
|
faiss.normalize_L2(self.xq)
|
|
|
|
gt_index = faiss.IndexFlatL2(self.d)
|
|
gt_index.add(self.xb)
|
|
_, self.I_gt = gt_index.search(self.xq, 10)
|
|
|
|
def _recall_at_1(self, index):
|
|
_, I = index.search(self.xq, 10)
|
|
return (self.I_gt[:, 0] == I[:, 0]).mean()
|
|
|
|
def test_flat_sqtq_search(self):
|
|
for sq_str in ["SQtq2", "SQtq3", "SQtq4"]:
|
|
with self.subTest(sq_str=sq_str):
|
|
index = faiss.index_factory(self.d, sq_str)
|
|
index.train(self.xb)
|
|
index.add(self.xb)
|
|
recall = self._recall_at_1(index)
|
|
self.assertGreater(recall, 0.05)
|
|
|
|
def test_recall_increases_with_bits(self):
|
|
recalls = {}
|
|
for sq_str in ["SQtq2", "SQtq3", "SQtq4"]:
|
|
index = faiss.index_factory(self.d, sq_str)
|
|
index.train(self.xb)
|
|
index.add(self.xb)
|
|
recalls[sq_str] = self._recall_at_1(index)
|
|
|
|
self.assertGreaterEqual(recalls["SQtq3"], recalls["SQtq2"] - 0.05)
|
|
self.assertGreaterEqual(recalls["SQtq4"], recalls["SQtq3"] - 0.05)
|
|
|
|
def test_ivf_sqtq_search(self):
|
|
for sq_str in ["SQtq2", "SQtq3"]:
|
|
with self.subTest(sq_str=sq_str):
|
|
factory_str = f"IVF32,{sq_str}"
|
|
index = faiss.index_factory(self.d, factory_str)
|
|
index.train(self.xb)
|
|
index.add(self.xb)
|
|
index.nprobe = 8
|
|
_, I = index.search(self.xq, 10)
|
|
self.assertEqual(I.shape, (self.nq, 10))
|
|
self.assertTrue(np.all(I[:, 0] >= 0))
|
|
|
|
|
|
class TestTurboQFullSerialization(unittest.TestCase):
|
|
"""Serialization roundtrip for TurboQ full types."""
|
|
|
|
def _roundtrip(self, index):
|
|
fd, fname = tempfile.mkstemp()
|
|
os.close(fd)
|
|
try:
|
|
faiss.write_index(index, fname)
|
|
index2 = faiss.read_index(fname)
|
|
return index2
|
|
finally:
|
|
os.unlink(fname)
|
|
|
|
def test_flat_serialize_roundtrip(self):
|
|
d = 64
|
|
rng = np.random.RandomState(42)
|
|
xb = rng.randn(500, d).astype("float32")
|
|
xq = rng.randn(10, d).astype("float32")
|
|
faiss.normalize_L2(xb)
|
|
faiss.normalize_L2(xq)
|
|
|
|
for qtype in [
|
|
faiss.ScalarQuantizer.QT_2bit_tq,
|
|
faiss.ScalarQuantizer.QT_3bit_tq,
|
|
faiss.ScalarQuantizer.QT_4bit_tq,
|
|
faiss.ScalarQuantizer.QT_5bit_tq,
|
|
]:
|
|
with self.subTest(qtype=qtype):
|
|
index = faiss.IndexScalarQuantizer(d, qtype)
|
|
index.train(xb)
|
|
index.add(xb)
|
|
|
|
D1, I1 = index.search(xq, 5)
|
|
index2 = self._roundtrip(index)
|
|
D2, I2 = index2.search(xq, 5)
|
|
|
|
np.testing.assert_array_equal(I1, I2)
|
|
np.testing.assert_allclose(D1, D2, atol=1e-6)
|
|
|
|
def test_ivf_serialize_roundtrip(self):
|
|
d = 64
|
|
rng = np.random.RandomState(42)
|
|
xb = rng.randn(1000, d).astype("float32")
|
|
xq = rng.randn(10, d).astype("float32")
|
|
faiss.normalize_L2(xb)
|
|
faiss.normalize_L2(xq)
|
|
|
|
for sq_str in ["SQtq2", "SQtq3"]:
|
|
with self.subTest(sq_str=sq_str):
|
|
index = faiss.index_factory(d, f"IVF16,{sq_str}")
|
|
index.train(xb)
|
|
index.add(xb)
|
|
index.nprobe = 4
|
|
|
|
D1, I1 = index.search(xq, 5)
|
|
index2 = self._roundtrip(index)
|
|
index2.nprobe = 4
|
|
D2, I2 = index2.search(xq, 5)
|
|
|
|
np.testing.assert_array_equal(I1, I2)
|
|
np.testing.assert_allclose(D1, D2, atol=1e-6)
|
|
|
|
def test_serialize_preserves_qtype(self):
|
|
d = 32
|
|
rng = np.random.RandomState(42)
|
|
xb = rng.randn(100, d).astype("float32")
|
|
faiss.normalize_L2(xb)
|
|
|
|
for qtype in [
|
|
faiss.ScalarQuantizer.QT_2bit_tq,
|
|
faiss.ScalarQuantizer.QT_3bit_tq,
|
|
]:
|
|
with self.subTest(qtype=qtype):
|
|
index = faiss.IndexScalarQuantizer(d, qtype)
|
|
index.train(xb)
|
|
index.add(xb)
|
|
|
|
index2 = self._roundtrip(index)
|
|
self.assertEqual(index2.sq.qtype, qtype)
|
|
self.assertEqual(index2.ntotal, index.ntotal)
|
|
self.assertEqual(index2.d, d)
|
|
|
|
|
|
class TestTurboQFullDistances(unittest.TestCase):
|
|
"""Verify distance computation is consistent for TurboQ."""
|
|
|
|
def test_search_distances_are_finite(self):
|
|
d = 64
|
|
rng = np.random.RandomState(42)
|
|
xb = rng.randn(500, d).astype("float32")
|
|
xq = rng.randn(10, d).astype("float32")
|
|
faiss.normalize_L2(xb)
|
|
faiss.normalize_L2(xq)
|
|
|
|
for qtype in [
|
|
faiss.ScalarQuantizer.QT_2bit_tq,
|
|
faiss.ScalarQuantizer.QT_3bit_tq,
|
|
]:
|
|
with self.subTest(qtype=qtype):
|
|
index = faiss.IndexScalarQuantizer(d, qtype)
|
|
index.train(xb)
|
|
index.add(xb)
|
|
|
|
D, I = index.search(xq, 5)
|
|
self.assertTrue(np.all(np.isfinite(D)))
|
|
self.assertTrue(np.all(D[:, 0] >= 0))
|
|
self.assertTrue(np.all(I >= 0))
|
|
|
|
def test_search_distances_sorted(self):
|
|
d = 64
|
|
rng = np.random.RandomState(42)
|
|
xb = rng.randn(500, d).astype("float32")
|
|
xq = rng.randn(10, d).astype("float32")
|
|
faiss.normalize_L2(xb)
|
|
faiss.normalize_L2(xq)
|
|
|
|
for qtype in [
|
|
faiss.ScalarQuantizer.QT_2bit_tq,
|
|
faiss.ScalarQuantizer.QT_3bit_tq,
|
|
]:
|
|
with self.subTest(qtype=qtype):
|
|
index = faiss.IndexScalarQuantizer(d, qtype)
|
|
index.train(xb)
|
|
index.add(xb)
|
|
|
|
D, _ = index.search(xq, 10)
|
|
for q in range(len(xq)):
|
|
for k in range(1, 10):
|
|
self.assertLessEqual(D[q, k - 1], D[q, k])
|
|
|
|
|
|
@for_all_simd_levels
|
|
class TestSQByteDirectAcrossSIMDLevels(unittest.TestCase):
|
|
"""Every SIMD level must return the exact QT_8bit_direct{,_signed}
|
|
distances.
|
|
|
|
The distances are integers, so this class computes them from the
|
|
definition and compares. An earlier version measured a second time at
|
|
SIMDLevel.NONE and compared the two runs. A static build reports only the
|
|
one level it compiles, and omits NONE, so that version skipped itself in
|
|
the build shape that ships.
|
|
|
|
d = 16 and d = 32 pass the d % 16 == 0 test in sq-dispatch.h. That test
|
|
routes the query to DistanceComputerByte and DistanceComputerByteSigned.
|
|
Two dispatch chains reach those kernels, and this class covers both.
|
|
search() uses sq_select_InvertedListScanner. get_distance_computer() uses
|
|
sq_select_distance_computer, which is the chain Refine(SQ8) follows.
|
|
"""
|
|
|
|
def reference(self, xq, xb, metric):
|
|
"""Returns the exact distances. The values are small integers. Every
|
|
product and sum stays below 2**24, so the float32 result loses
|
|
nothing."""
|
|
a = xq.astype("int64")
|
|
b = xb.astype("int64")
|
|
if metric == faiss.METRIC_L2:
|
|
return ((a[:, None, :] - b[None, :, :]) ** 2).sum(-1)
|
|
return a @ b.T
|
|
|
|
def do_test(self, measure, expect):
|
|
rng = np.random.RandomState(1234)
|
|
for qtype in (
|
|
faiss.ScalarQuantizer.QT_8bit_direct,
|
|
faiss.ScalarQuantizer.QT_8bit_direct_signed,
|
|
):
|
|
lo, hi = (
|
|
(-128, 128)
|
|
if qtype == faiss.ScalarQuantizer.QT_8bit_direct_signed
|
|
else (0, 256)
|
|
)
|
|
for metric in (faiss.METRIC_L2, faiss.METRIC_INNER_PRODUCT):
|
|
for d in (16, 32):
|
|
with self.subTest(qtype=qtype, metric=metric, d=d):
|
|
xb = rng.randint(lo, hi, (500, d)).astype("float32")
|
|
xq = rng.randint(lo, hi, (10, d)).astype("float32")
|
|
index = faiss.IndexScalarQuantizer(d, qtype, metric)
|
|
index.add(xb)
|
|
got = measure(index, xq)
|
|
truth = self.reference(xq, xb, metric)
|
|
np.testing.assert_array_equal(got, expect(truth, metric))
|
|
|
|
def test_scanner(self):
|
|
k = 10
|
|
|
|
def measure(index, xq):
|
|
return index.search(xq, k)[0]
|
|
|
|
def expect(truth, metric):
|
|
# search returns L2 ascending and inner product descending.
|
|
ordered = np.sort(truth, axis=1)
|
|
if metric == faiss.METRIC_INNER_PRODUCT:
|
|
ordered = ordered[:, ::-1]
|
|
return ordered[:, :k]
|
|
|
|
self.do_test(measure, expect)
|
|
|
|
def test_distance_computer(self):
|
|
def measure(index, xq):
|
|
dc = index.get_distance_computer()
|
|
out = np.empty((len(xq), index.ntotal), dtype="float32")
|
|
for q in range(len(xq)):
|
|
dc.set_query(faiss.swig_ptr(xq[q]))
|
|
for i in range(index.ntotal):
|
|
out[q, i] = dc(int(i))
|
|
return out
|
|
|
|
self.do_test(measure, lambda truth, metric: truth)
|