mirror of
https://github.com/facebookresearch/faiss.git
synced 2026-10-12 02:13:36 +00:00
Summary:
This PR introduces `IndexSQFastScan` and `IndexIVFSQFastScan`, fast scan variants of scalar quantized indexes.
**IndexSQFastScan** (33f2cb639bd7a888b2ab7578083b73753c2fd3d3)
Introduces `IndexSQFastScan`, a fast scan variant of `IndexScalarQuantizer` that leverages the PQ4 FastScan SIMD infrastructure (`vpshufb`) for accelerated search over scalar-quantized codes.
Three code paths depending on quantizer type:
- Native 4-bit (`QT_4bit`, `QT_4bit_uniform`): codes map directly onto the 16-level PQ4 fast scan. ~11x speedup, no precision loss.
- Rerank (`QT_6bit`, `QT_8bit`, `QT_8bit_uniform`, `QT_8bit_direct`, `QT_8bit_direct_signed`): re-quantize to 4-bit for a coarse fast scan first pass, then rerank top-k*rerank_factor candidates with exact original-precision distances. ~7-9x speedup, near-zero precision loss at rerank_factor=4.
- Fallback (`QT_fp16`, `QT_bf16`, `TurboQuant`, etc.): delegates to the ScalarQuantizer's own SIMD-optimized scanner. Same speed as IndexScalarQuantizer — no fast scan benefit, but provides a unified interface for all quantizer types.
**benchmark**: `benchs/bench_index_sq_fastscan.py`
```text
+---------------------+----------------------+---------+---------+--------+--------+
| Category | QType | Speedup | ms/q SQ | R@1 | R@10 |
+---------------------+----------------------+---------+---------+--------+--------+
| Native 4-bit (SIMD) | QT_4bit | 11.9x | 0.240 | 0.7714 | 0.9985 |
| | QT_4bit_uniform | 11.7x | 0.235 | 0.7180 | 0.9952 |
+---------------------+----------------------+----------+---------+--------+--------+
| Rerank (rf=4) | QT_6bit | 9.3x | 0.202 | 0.9371 | 1.0000 |
| | QT_8bit | 8.4x | 0.173 | 0.9783 | 1.0000 |
| | QT_8bit_uniform | 6.7x | 0.138 | 0.9728 | 1.0000 |
| | QT_8bit_direct | 4.7x | 0.101 | 0.9914 | 1.0000 |
| | QT_8bit_direct_signed| 4.9x | 0.101 | 0.5999 | 0.6591 |
+---------------------+----------------------+----------+---------+--------+--------+
| Fallback (~1.0x) | QT_fp16 | 0.9x | 0.373 | 0.9914 | 1.0000 |
| | QT_bf16 | 1.0x | 0.365 | 0.9914 | 1.0000 |
| | QT_8bit_tqmse | 1.0x | 0.261 | 0.6033 | 0.9561 |
| | QT_5bit_tq | 1.0x | 3.013 | 0.2680 | 0.6248 |
| | QT_4bit_tq | 1.0x | 2.416 | 0.1511 | 0.3994 |
| | QT_3bit_tq | 1.0x | 0.411 | 0.0487 | 0.1691 |
| | QT_2bit_tq | 1.1x | 0.231 | 0.0376 | 0.1532 |
| | QT_4bit_tqmse | 1.0x | 0.290 | 0.0012 | 0.0064 |
| | QT_3bit_tqmse | 1.0x | 0.345 | 0.0000 | 0.0001 |
| | QT_2bit_tqmse | 1.0x | 0.266 | 0.0000 | 0.0001 |
| | QT_1bit_tqmse | 1.0x | 0.280 | 0.0000 | 0.0001 |
+---------------------+----------------------+----------+---------+--------+--------+
Key: Speedup = IndexScalarQuantizer ms / IndexSQFastScan ms
R@1, R@10 = IndexSQFastScan recall (matches SQ for all fallback types)
Rerank shows rf=4 (rf=2 and rf=8 yield same recall, ~0.1x speedup variation)
Dataset: SIFT1M, k=32
```
**IndexIVFSQFastScan** (ebfa072816df2fbd80a6b463a913866f862587f6)
Similar to the above, this commit introduces `IndexIVFSQFastScan`, an IVF variant of scalar quantized fast scan that extends the PQ4 FastScan SIMD infrastructure (vpshufb) to inverted-list indexes with scalar quantization. Uses BlockInvertedLists for packed 4-bit codes scanned via SIMD. Will continue to update the PR until it's feature complete.
Three code paths depending on quantizer type:
- Native 4-bit (QT_4bit, QT_4bit_uniform): codes pack directly into the SIMD block layout with no precision loss. ~12x speedup.
- Rerank (QT_6bit, QT_8bit, QT_8bit_uniform, QT_8bit_direct, QT_8bit_direct_signed): re-quantized to 4-bit for the SIMD coarse pass, then reranked using exact original-precision codes stored in a parallel ArrayInvertedLists. ~8-10x speedup, near-zero precision loss at rerank_factor=2.
- Fallback (QT_fp16, QT_bf16, TurboQuant, etc.): delegates to IndexIVF's InvertedListScanner path. Same behavior as IndexIVFScalarQuantizer — no fast scan benefit, but provides a unified interface for all quantizer types.
**benchmark (SIFT1M, d=128, k=32, SapphireRapids)**: `benchs/bench_index_ivf_sq_fastscan.py`
```text
+---------------------+----------------------+--------+-------------------------------------------+
| Category | QType | nprobe | Speedup | ms/q SQ | R@1 FS | R@10 FS |
+---------------------+----------------------+--------+---------+---------+--------+-------------+
| Native 4-bit (SIMD) | QT_4bit | 1 | 7.1x | 0.007 | 0.4582 | 0.5456 |
| | | 4 | 10.4x | 0.007 | 0.6903 | 0.8667 |
| | | 16 | 12.9x | 0.022 | 0.7668 | 0.9885 |
| | | 64 | 12.1x | 0.070 | 0.7725 | 0.9983 |
| | | 256 | 11.3x | 0.230 | 0.7725 | 0.9983 |
| +----------------------+--------+---------+---------+--------+-------------+
| | QT_4bit_uniform | 1 | 15.8x | 0.006 | 0.4397 | 0.5449 |
| | | 4 | 10.5x | 0.007 | 0.6419 | 0.8650 |
| | | 16 | 10.4x | 0.018 | 0.7113 | 0.9855 |
| | | 64 | 10.9x | 0.063 | 0.7162 | 0.9951 |
| | | 256 | 11.0x | 0.222 | 0.7162 | 0.9951 |
+---------------------+----------------------+--------+---------+---------+--------+-------------+
| Rerank (rf=4) | QT_6bit | 1 | 4.9x | 0.007 | 0.4582 | 0.5456 |
| | | 4 | 4.8x | 0.007 | 0.6903 | 0.8667 |
| | | 16 | 7.9x | 0.018 | 0.7668 | 0.9885 |
| | | 64 | 10.4x | 0.067 | 0.7725 | 0.9983 |
| | | 256 | 10.2x | 0.221 | 0.7725 | 0.9983 |
| +----------------------+--------+---------+---------+--------+-------------+
| | QT_8bit | 1 | 17.1x | 0.007 | 0.4582 | 0.5456 |
| | | 4 | 12.5x | 0.008 | 0.6903 | 0.8667 |
| | | 16 | 8.9x | 0.015 | 0.7668 | 0.9885 |
| | | 64 | 8.4x | 0.049 | 0.7725 | 0.9983 |
| | | 256 | 9.3x | 0.188 | 0.7725 | 0.9983 |
| +----------------------+--------+---------+---------+--------+-------------+
| | QT_8bit_uniform | 1 | 18.2x | 0.007 | 0.4397 | 0.5449 |
| | | 4 | 11.1x | 0.008 | 0.6419 | 0.8650 |
| | | 16 | 8.3x | 0.014 | 0.7113 | 0.9855 |
| | | 64 | 7.8x | 0.045 | 0.7162 | 0.9951 |
| | | 256 | 7.8x | 0.164 | 0.7162 | 0.9951 |
| +----------------------+--------+---------+---------+--------+-------------+
| | QT_8bit_direct | 1 | 8.6x | 0.007 | 0.4083 | 0.5426 |
| | | 4 | 7.0x | 0.008 | 0.5987 | 0.8587 |
| | | 16 | 6.8x | 0.013 | 0.6550 | 0.9756 |
| | | 64 | 6.1x | 0.035 | 0.6581 | 0.9850 |
| | | 256 | 7.4x | 0.150 | 0.6581 | 0.9850 |
| +----------------------+--------+---------+---------+--------+-------------+
| | QT_8bit_direct_signed| 1 | 16.5x | 0.006 | 0.4257 | 0.5447 |
| | | 4 | 10.1x | 0.007 | 0.6286 | 0.8633 |
| | | 16 | 7.1x | 0.012 | 0.6919 | 0.9831 |
| | | 64 | 5.7x | 0.033 | 0.6963 | 0.9923 |
| | | 256 | 7.1x | 0.145 | 0.6963 | 0.9923 |
+---------------------+----------------------+--------+---------+---------+--------+-------------+
| Fallback (~1.0x) | QT_fp16 | 1 | 1.0x | 0.007 | 0.5434 | 0.5462 |
| | | 4 | 1.1x | 0.013 | 0.8607 | 0.8678 |
| | | 16 | 0.9x | 0.035 | 0.9814 | 0.9902 |
| | | 64 | 0.9x | 0.115 | 0.9912 | 1.0000 |
| | | 256 | 0.9x | 0.481 | 0.9912 | 1.0000 |
| +----------------------+--------+---------+---------+--------+-------------+
| | QT_bf16 | 1 | 1.0x | 0.008 | 0.5427 | 0.5462 |
| | | 4 | 1.1x | 0.013 | 0.8581 | 0.8678 |
| | | 16 | 1.0x | 0.035 | 0.9778 | 0.9902 |
| | | 64 | 1.0x | 0.115 | 0.9876 | 1.0000 |
| | | 256 | 1.0x | 0.487 | 0.9876 | 1.0000 |
| +----------------------+--------+---------+---------+--------+-------------+
| | QT_5bit_tq | 1 | 1.0x | 0.016 | 0.1866 | 0.3827 |
| | | 64 | 1.0x | 0.794 | 0.2680 | 0.6248 |
| | | 256 | 1.0x | 2.885 | 0.2680 | 0.6248 |
| +----------------------+--------+---------+---------+--------+-------------+
| | QT_4bit_tq | 1 | 1.0x | 0.013 | 0.1182 | 0.2841 |
| | | 64 | 0.9x | 0.618 | 0.1511 | 0.3994 |
| | | 256 | 0.9x | 2.298 | 0.1511 | 0.3994 |
| +----------------------+--------+---------+---------+--------+-------------+
| | QT_3bit_tq | 1 | 1.1x | 0.008 | 0.0526 | 0.1599 |
| | | 64 | 0.8x | 0.092 | 0.0488 | 0.1696 |
| | | 256 | 0.8x | 0.357 | 0.0487 | 0.1691 |
| +----------------------+--------+---------+---------+--------+-------------+
| | QT_2bit_tq | 1 | 1.2x | 0.008 | 0.0450 | 0.1526 |
| | | 64 | 1.1x | 0.072 | 0.0377 | 0.1534 |
| | | 256 | 1.1x | 0.224 | 0.0376 | 0.1532 |
| +----------------------+--------+---------+---------+--------+-------------+
| | QT_4bit_tqmse | 1 | 1.0x | 0.007 | 0.0619 | 0.1851 |
| | | 64 | 1.0x | 0.072 | 0.0002 | 0.0010 |
| | | 256 | 0.9x | 0.275 | 0.0000 | 0.0000 |
| +----------------------+--------+---------+---------+--------+-------------+
| | QT_3bit_tqmse | 1 | 1.0x | 0.007 | 0.0621 | 0.1852 |
| | | 64 | 1.0x | 0.090 | 0.0006 | 0.0032 |
| | | 256 | 1.0x | 0.350 | 0.0000 | 0.0000 |
| +----------------------+--------+---------+---------+--------+-------------+
| | QT_2bit_tqmse | 1 | 1.1x | 0.007 | 0.0621 | 0.1852 |
| | | 64 | 1.0x | 0.068 | 0.0015 | 0.0080 |
| | | 256 | 1.0x | 0.244 | 0.0000 | 0.0000 |
+---------------------+----------------------+--------+---------+---------+--------+-------------+
Key: Speedup = IndexIVFScalarQuantizer ms / IndexIVFSQFastScan ms
R@1, R@10 = IVFSQFastScan recall (matches IVF-SQ for all fallback types)
Rerank shows rf=4 only (rf=2,8 yield similar results)
Fallback shows nprobe=1,64,256 only (abbreviated for low-recall types)
Dataset: SIFT1M, nlist=256, k=32
```
Pull Request resolved: https://github.com/facebookresearch/faiss/pull/5341
Reviewed By: junjieqi
Differential Revision: D117259284
Pulled By: mnorris11
fbshipit-source-id: 41455097ff3ae1285aeb4cf88e07abc5b5b13171
490 lines
17 KiB
Python
490 lines
17 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.
|
|
|
|
"""
|
|
Unit tests for IndexSQFastScan.
|
|
|
|
Tests cover:
|
|
- Construction for native 4-bit types only
|
|
- Rejection of non-4-bit types
|
|
- Train / add / search round-trip
|
|
- Recall parity with IndexScalarQuantizer (within expected bounds)
|
|
- Conversion constructor from IndexScalarQuantizer
|
|
- Reset clears state
|
|
- Edge cases: k=1, single vector, zero vectors, odd dimensions
|
|
- Inner product metric
|
|
- sa_encode / sa_decode consistency
|
|
- I/O round-trip
|
|
- Factory strings
|
|
- get_distance_computer
|
|
- range_search
|
|
"""
|
|
|
|
from __future__ import absolute_import, division, print_function
|
|
|
|
import os
|
|
import tempfile
|
|
import unittest
|
|
|
|
import numpy as np
|
|
|
|
import faiss
|
|
from faiss.contrib.datasets import SyntheticDataset
|
|
|
|
|
|
def recall_at_k(I_gt, I_test, k):
|
|
"""Fraction of queries whose true NN (gt[:,0]) appears in test[:,:k]."""
|
|
return np.mean([I_gt[i, 0] in I_test[i, :k] for i in range(len(I_gt))])
|
|
|
|
|
|
# Native 4-bit types (only types supported by IndexSQFastScan)
|
|
SQ = faiss.ScalarQuantizer
|
|
|
|
NATIVE_4BIT = [
|
|
("QT_4bit", SQ.QT_4bit),
|
|
("QT_4bit_uniform", SQ.QT_4bit_uniform),
|
|
]
|
|
|
|
|
|
class TestSQFastScanConstruction(unittest.TestCase):
|
|
"""IndexSQFastScan can be constructed for native 4-bit types only."""
|
|
|
|
def test_native_4bit_construct(self):
|
|
d = 64
|
|
for name, qtype in NATIVE_4BIT:
|
|
with self.subTest(qtype=name):
|
|
index = faiss.IndexSQFastScan(d, qtype)
|
|
self.assertEqual(index.d, d)
|
|
self.assertFalse(index.is_trained)
|
|
self.assertEqual(index.ntotal, 0)
|
|
|
|
def test_default_construct(self):
|
|
index = faiss.IndexSQFastScan()
|
|
self.assertEqual(index.d, 0)
|
|
self.assertEqual(index.ntotal, 0)
|
|
|
|
def test_rejects_non_4bit(self):
|
|
d = 64
|
|
with self.assertRaises(RuntimeError):
|
|
faiss.IndexSQFastScan(d, SQ.QT_8bit)
|
|
with self.assertRaises(RuntimeError):
|
|
faiss.IndexSQFastScan(d, SQ.QT_6bit)
|
|
with self.assertRaises(RuntimeError):
|
|
faiss.IndexSQFastScan(d, SQ.QT_fp16)
|
|
with self.assertRaises(RuntimeError):
|
|
faiss.IndexSQFastScan(d, SQ.QT_bf16)
|
|
|
|
|
|
class TestSQFastScanTrainAddSearch(unittest.TestCase):
|
|
"""Basic train/add/search works for native 4-bit types."""
|
|
|
|
def setUp(self):
|
|
self.d = 64
|
|
self.nb = 5000
|
|
self.nq = 50
|
|
self.k = 10
|
|
self.ds = SyntheticDataset(d=self.d, nt=2000, nb=self.nb,
|
|
nq=self.nq, seed=42)
|
|
|
|
def test_native_4bit(self):
|
|
for name, qtype in NATIVE_4BIT:
|
|
with self.subTest(qtype=name):
|
|
index = faiss.IndexSQFastScan(self.d, qtype)
|
|
index.train(self.ds.get_train())
|
|
self.assertTrue(index.is_trained)
|
|
|
|
index.add(self.ds.get_database())
|
|
self.assertEqual(index.ntotal, self.nb)
|
|
|
|
D, I = index.search(self.ds.get_queries(), self.k)
|
|
self.assertEqual(D.shape, (self.nq, self.k))
|
|
self.assertEqual(I.shape, (self.nq, self.k))
|
|
self.assertTrue(np.all(I[:, 0] >= 0))
|
|
self.assertTrue(np.all(np.isfinite(D)))
|
|
for q in range(self.nq):
|
|
for j in range(1, self.k):
|
|
self.assertLessEqual(D[q, j - 1], D[q, j] + 1e-5)
|
|
|
|
|
|
class TestSQFastScanRecallParity(unittest.TestCase):
|
|
"""IndexSQFastScan recall should match IndexScalarQuantizer for 4-bit."""
|
|
|
|
def setUp(self):
|
|
self.d = 64
|
|
self.ds = SyntheticDataset(d=self.d, nt=2000, nb=10000,
|
|
nq=100, seed=42)
|
|
gt_index = faiss.IndexFlatL2(self.d)
|
|
gt_index.add(self.ds.get_database())
|
|
self.D_gt, self.I_gt = gt_index.search(self.ds.get_queries(), 10)
|
|
|
|
def test_native_4bit_parity(self):
|
|
"""Native 4-bit: FastScan should match SQ recall exactly."""
|
|
for name, qtype in NATIVE_4BIT:
|
|
with self.subTest(qtype=name):
|
|
sq = faiss.IndexScalarQuantizer(self.d, qtype)
|
|
sq.train(self.ds.get_train())
|
|
sq.add(self.ds.get_database())
|
|
|
|
fs = faiss.IndexSQFastScan(self.d, qtype)
|
|
fs.train(self.ds.get_train())
|
|
fs.add(self.ds.get_database())
|
|
|
|
_, I_sq = sq.search(self.ds.get_queries(), 10)
|
|
_, I_fs = fs.search(self.ds.get_queries(), 10)
|
|
r_sq = recall_at_k(self.I_gt, I_sq, 1)
|
|
r_fs = recall_at_k(self.I_gt, I_fs, 1)
|
|
self.assertAlmostEqual(r_sq, r_fs, delta=0.05,
|
|
msg=f"{name}: SQ={r_sq:.3f} FS={r_fs:.3f}")
|
|
|
|
|
|
class TestSQFastScanConversionConstructor(unittest.TestCase):
|
|
"""Conversion from IndexScalarQuantizer preserves search results."""
|
|
|
|
def setUp(self):
|
|
self.d = 64
|
|
self.ds = SyntheticDataset(d=self.d, nt=2000, nb=5000,
|
|
nq=50, seed=42)
|
|
|
|
def test_conversion_native_4bit(self):
|
|
for name, qtype in NATIVE_4BIT:
|
|
with self.subTest(qtype=name):
|
|
sq = faiss.IndexScalarQuantizer(self.d, qtype)
|
|
sq.train(self.ds.get_train())
|
|
sq.add(self.ds.get_database())
|
|
|
|
fs = faiss.IndexSQFastScan(sq)
|
|
self.assertEqual(fs.ntotal, sq.ntotal)
|
|
self.assertTrue(fs.is_trained)
|
|
|
|
D_sq, I_sq = sq.search(self.ds.get_queries(), 10)
|
|
D_fs, I_fs = fs.search(self.ds.get_queries(), 10)
|
|
r1_sq = (I_sq[:, 0] >= 0).mean()
|
|
r1_fs = (I_fs[:, 0] >= 0).mean()
|
|
self.assertEqual(r1_sq, r1_fs)
|
|
|
|
def test_conversion_rejects_non_4bit(self):
|
|
sq = faiss.IndexScalarQuantizer(self.d, SQ.QT_8bit)
|
|
sq.train(self.ds.get_train())
|
|
with self.assertRaises(RuntimeError):
|
|
faiss.IndexSQFastScan(sq)
|
|
|
|
|
|
class TestSQFastScanReset(unittest.TestCase):
|
|
"""Reset should clear all stored data."""
|
|
|
|
def test_reset(self):
|
|
d = 64
|
|
ds = SyntheticDataset(d=d, nt=1000, nb=500, nq=0, seed=42)
|
|
for name, qtype in NATIVE_4BIT:
|
|
with self.subTest(qtype=name):
|
|
index = faiss.IndexSQFastScan(d, qtype)
|
|
index.train(ds.get_train())
|
|
index.add(ds.get_database())
|
|
self.assertEqual(index.ntotal, 500)
|
|
|
|
index.reset()
|
|
self.assertEqual(index.ntotal, 0)
|
|
|
|
index.add(ds.get_database())
|
|
self.assertEqual(index.ntotal, 500)
|
|
|
|
|
|
class TestSQFastScanEdgeCases(unittest.TestCase):
|
|
"""Edge cases: k=1, single vector, zero vectors, odd dimensions."""
|
|
|
|
def test_k_equals_1(self):
|
|
d = 32
|
|
ds = SyntheticDataset(d=d, nt=500, nb=1000, nq=10, seed=42)
|
|
for name, qtype in NATIVE_4BIT:
|
|
with self.subTest(qtype=name):
|
|
index = faiss.IndexSQFastScan(d, qtype)
|
|
index.train(ds.get_train())
|
|
index.add(ds.get_database())
|
|
D, I = index.search(ds.get_queries(), 1)
|
|
self.assertEqual(D.shape, (10, 1))
|
|
self.assertEqual(I.shape, (10, 1))
|
|
self.assertTrue(np.all(I >= 0))
|
|
|
|
def test_single_vector(self):
|
|
d = 32
|
|
xb = np.random.RandomState(42).randn(1, d).astype('float32')
|
|
xq = xb.copy()
|
|
for name, qtype in NATIVE_4BIT:
|
|
with self.subTest(qtype=name):
|
|
index = faiss.IndexSQFastScan(d, qtype)
|
|
index.train(xb)
|
|
index.add(xb)
|
|
D, I = index.search(xq, 1)
|
|
self.assertEqual(I[0, 0], 0)
|
|
self.assertLess(D[0, 0], 1.0)
|
|
|
|
def test_zero_vectors(self):
|
|
d = 32
|
|
xb = np.zeros((100, d), dtype='float32')
|
|
for name, qtype in NATIVE_4BIT:
|
|
with self.subTest(qtype=name):
|
|
index = faiss.IndexSQFastScan(d, qtype)
|
|
index.train(xb)
|
|
index.add(xb)
|
|
D, I = index.search(np.zeros((1, d), dtype='float32'), 10)
|
|
self.assertTrue(np.all(np.isfinite(D)))
|
|
|
|
def test_odd_dimensions(self):
|
|
"""Dimensions not aligned to SIMD width."""
|
|
for d in [7, 9, 15, 17, 31, 33, 63, 65]:
|
|
with self.subTest(d=d):
|
|
ds = SyntheticDataset(d=d, nt=500, nb=500, nq=5, seed=42)
|
|
index = faiss.IndexSQFastScan(d, SQ.QT_4bit)
|
|
index.train(ds.get_train())
|
|
index.add(ds.get_database())
|
|
D, I = index.search(ds.get_queries(), 10)
|
|
self.assertEqual(D.shape, (5, 10))
|
|
self.assertTrue(np.all(I[:, 0] >= 0))
|
|
|
|
|
|
class TestSQFastScanInnerProduct(unittest.TestCase):
|
|
"""Inner product metric works correctly."""
|
|
|
|
def test_ip_basic(self):
|
|
d = 64
|
|
ds = SyntheticDataset(d=d, nt=2000, nb=5000, nq=50, seed=42)
|
|
xb = ds.get_database().copy()
|
|
xq = ds.get_queries().copy()
|
|
faiss.normalize_L2(xb)
|
|
faiss.normalize_L2(xq)
|
|
|
|
for name, qtype in NATIVE_4BIT:
|
|
with self.subTest(qtype=name):
|
|
index = faiss.IndexSQFastScan(
|
|
d, qtype, faiss.METRIC_INNER_PRODUCT)
|
|
index.train(xb)
|
|
index.add(xb)
|
|
D, I = index.search(xq, 10)
|
|
self.assertEqual(D.shape, (50, 10))
|
|
self.assertTrue(np.all(I[:, 0] >= 0))
|
|
self.assertTrue(np.all(np.isfinite(D)))
|
|
for q in range(50):
|
|
for j in range(1, 10):
|
|
self.assertGreaterEqual(
|
|
D[q, j - 1] + 1e-5, D[q, j])
|
|
|
|
|
|
class TestSQFastScanSaDecode(unittest.TestCase):
|
|
"""sa_decode should reconstruct vectors close to the original."""
|
|
|
|
def test_sa_decode(self):
|
|
d = 64
|
|
ds = SyntheticDataset(d=d, nt=1000, nb=100, nq=0, seed=42)
|
|
for name, qtype in NATIVE_4BIT:
|
|
with self.subTest(qtype=name):
|
|
index = faiss.IndexSQFastScan(d, qtype)
|
|
index.train(ds.get_train())
|
|
index.add(ds.get_database())
|
|
|
|
xr = index.reconstruct_n(0, index.ntotal)
|
|
self.assertEqual(xr.shape, (100, d))
|
|
self.assertTrue(np.all(np.isfinite(xr)))
|
|
|
|
|
|
class TestSQFastScanIncrementalAdd(unittest.TestCase):
|
|
"""Adding vectors in batches should give same results as all-at-once."""
|
|
|
|
def test_incremental_add(self):
|
|
d = 64
|
|
ds = SyntheticDataset(d=d, nt=1000, nb=1000, nq=20, seed=42)
|
|
xb = ds.get_database()
|
|
xq = ds.get_queries()
|
|
|
|
for name, qtype in NATIVE_4BIT:
|
|
with self.subTest(qtype=name):
|
|
idx1 = faiss.IndexSQFastScan(d, qtype)
|
|
idx1.train(ds.get_train())
|
|
idx1.add(xb)
|
|
|
|
idx2 = faiss.IndexSQFastScan(d, qtype)
|
|
idx2.train(ds.get_train())
|
|
idx2.add(xb[:500])
|
|
idx2.add(xb[500:])
|
|
|
|
self.assertEqual(idx1.ntotal, idx2.ntotal)
|
|
|
|
D1, I1 = idx1.search(xq, 10)
|
|
D2, I2 = idx2.search(xq, 10)
|
|
|
|
np.testing.assert_array_equal(I1, I2)
|
|
np.testing.assert_allclose(D1, D2, atol=1e-5)
|
|
|
|
|
|
class TestSQFastScanSaEncode(unittest.TestCase):
|
|
"""sa_encode / sa_decode round-trip matches IndexScalarQuantizer."""
|
|
|
|
def test_sa_encode_matches_sq(self):
|
|
d = 64
|
|
ds = SyntheticDataset(d=d, nt=1000, nb=100, nq=0, seed=42)
|
|
for name, qtype in NATIVE_4BIT:
|
|
with self.subTest(qtype=name):
|
|
sq = faiss.IndexScalarQuantizer(d, qtype)
|
|
sq.train(ds.get_train())
|
|
|
|
fs = faiss.IndexSQFastScan(d, qtype)
|
|
fs.train(ds.get_train())
|
|
|
|
xb = ds.get_database()
|
|
cs = fs.sa_code_size()
|
|
|
|
sq_codes = sq.sa_encode(xb)
|
|
fs_codes = fs.sa_encode(xb)
|
|
np.testing.assert_array_equal(sq_codes, fs_codes,
|
|
err_msg=f"{name}: sa_encode differs")
|
|
|
|
sq_dec = sq.sa_decode(sq_codes)
|
|
fs_dec = fs.sa_decode(fs_codes)
|
|
np.testing.assert_allclose(sq_dec, fs_dec, atol=1e-6,
|
|
err_msg=f"{name}: sa_decode differs")
|
|
|
|
|
|
class TestSQFastScanAddSaCodes(unittest.TestCase):
|
|
"""add_sa_codes should produce working indexes."""
|
|
|
|
def test_add_sa_codes(self):
|
|
d = 64
|
|
ds = SyntheticDataset(d=d, nt=1000, nb=500, nq=20, seed=42)
|
|
for name, qtype in NATIVE_4BIT:
|
|
with self.subTest(qtype=name):
|
|
ref = faiss.IndexSQFastScan(d, qtype)
|
|
ref.train(ds.get_train())
|
|
ref.add(ds.get_database())
|
|
|
|
test = faiss.IndexSQFastScan(d, qtype)
|
|
test.train(ds.get_train())
|
|
codes = test.sa_encode(ds.get_database())
|
|
test.add_sa_codes(codes)
|
|
|
|
self.assertEqual(ref.ntotal, test.ntotal)
|
|
|
|
D_ref, I_ref = ref.search(ds.get_queries(), 10)
|
|
D_test, I_test = test.search(ds.get_queries(), 10)
|
|
self.assertTrue(np.all(I_ref[:, 0] >= 0))
|
|
self.assertTrue(np.all(I_test[:, 0] >= 0))
|
|
|
|
|
|
class TestSQFastScanPermuteEntries(unittest.TestCase):
|
|
"""permute_entries reorders the packed codes."""
|
|
|
|
def test_permute_entries(self):
|
|
d, nb = 64, 100
|
|
ds = SyntheticDataset(d=d, nt=1000, nb=nb, nq=0, seed=42)
|
|
for name, qtype in NATIVE_4BIT:
|
|
with self.subTest(qtype=name):
|
|
index = faiss.IndexSQFastScan(d, qtype)
|
|
index.train(ds.get_train())
|
|
index.add(ds.get_database())
|
|
|
|
before = index.reconstruct_n(0, nb)
|
|
perm = np.arange(nb - 1, -1, -1).astype("int64")
|
|
index.permute_entries(perm)
|
|
|
|
np.testing.assert_allclose(
|
|
index.reconstruct_n(0, nb), before[perm], atol=1e-5)
|
|
|
|
|
|
class TestSQFastScanDistanceComputer(unittest.TestCase):
|
|
"""get_distance_computer works for native 4-bit types."""
|
|
|
|
def test_distance_computer(self):
|
|
d = 64
|
|
ds = SyntheticDataset(d=d, nt=1000, nb=500, nq=0, seed=42)
|
|
for name, qtype in NATIVE_4BIT:
|
|
with self.subTest(qtype=name):
|
|
index = faiss.IndexSQFastScan(d, qtype)
|
|
index.train(ds.get_train())
|
|
index.add(ds.get_database())
|
|
dc = index.get_distance_computer()
|
|
self.assertIsNotNone(dc)
|
|
|
|
|
|
class TestSQFastScanRangeSearch(unittest.TestCase):
|
|
"""range_search produces results consistent with kNN search."""
|
|
|
|
def test_range_search(self):
|
|
d = 64
|
|
ds = SyntheticDataset(d=d, nt=1000, nb=1000, nq=10, seed=42)
|
|
for name, qtype in NATIVE_4BIT:
|
|
with self.subTest(qtype=name):
|
|
sq = faiss.IndexScalarQuantizer(d, qtype)
|
|
sq.train(ds.get_train())
|
|
sq.add(ds.get_database())
|
|
|
|
fs = faiss.IndexSQFastScan(d, qtype)
|
|
fs.train(ds.get_train())
|
|
fs.add(ds.get_database())
|
|
|
|
D, _ = sq.search(ds.get_queries()[:1], 1000)
|
|
radius = float(D[0, 500])
|
|
|
|
lims_sq, D_sq, I_sq = sq.range_search(
|
|
ds.get_queries(), radius)
|
|
lims_fs, D_fs, I_fs = fs.range_search(
|
|
ds.get_queries(), radius)
|
|
|
|
np.testing.assert_array_equal(lims_sq, lims_fs,
|
|
err_msg=f"{name}: lims differ")
|
|
|
|
|
|
class TestSQFastScanIO(unittest.TestCase):
|
|
"""I/O round-trip preserves index state and search results."""
|
|
|
|
def test_io_roundtrip(self):
|
|
d = 64
|
|
ds = SyntheticDataset(d=d, nt=1000, nb=500, nq=20, seed=42)
|
|
for name, qtype in NATIVE_4BIT:
|
|
with self.subTest(qtype=name):
|
|
index = faiss.IndexSQFastScan(d, qtype)
|
|
index.train(ds.get_train())
|
|
index.add(ds.get_database())
|
|
|
|
D1, I1 = index.search(ds.get_queries(), 10)
|
|
|
|
with tempfile.NamedTemporaryFile(suffix='.idx', delete=False) as f:
|
|
fname = f.name
|
|
try:
|
|
faiss.write_index(index, fname)
|
|
loaded = faiss.read_index(fname)
|
|
finally:
|
|
os.unlink(fname)
|
|
|
|
self.assertIsInstance(loaded, faiss.IndexSQFastScan)
|
|
self.assertEqual(loaded.ntotal, index.ntotal)
|
|
self.assertEqual(loaded.d, d)
|
|
|
|
D2, I2 = loaded.search(ds.get_queries(), 10)
|
|
np.testing.assert_array_equal(I1, I2)
|
|
np.testing.assert_allclose(D1, D2, atol=1e-5)
|
|
|
|
|
|
class TestSQFastScanFactory(unittest.TestCase):
|
|
"""Factory string produces IndexSQFastScan."""
|
|
|
|
def test_factory_strings(self):
|
|
d = 64
|
|
index = faiss.index_factory(d, "SQ4fs")
|
|
self.assertIsInstance(index, faiss.IndexSQFastScan)
|
|
self.assertEqual(index.d, d)
|
|
|
|
def test_factory_with_bbs(self):
|
|
d = 64
|
|
index = faiss.index_factory(d, "SQ4fs_64")
|
|
self.assertIsInstance(index, faiss.IndexSQFastScan)
|
|
self.assertEqual(index.bbs, 64)
|
|
|
|
def test_factory_metric(self):
|
|
d = 64
|
|
index = faiss.index_factory(d, "SQ4fs", faiss.METRIC_INNER_PRODUCT)
|
|
self.assertIsInstance(index, faiss.IndexSQFastScan)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|