Files
faiss/tests/test_sq_fastscan.py
T
Mulugeta Mammo b0d9e80288 Add fast scan variants of SQ indexes - IndexSQFastScan and IndexIVFSQFastScan (#5341)
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
2026-09-18 20:24:54 -07:00

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()