Files
faiss/tests/test_product_quantizer.py
anthony-zy 1768a5a8e9 Add RVV SIMD specialization for product quantizer distance-table computation (#5543)
Summary:
Add a RISC-V Vector Extension implementation for the product-quantizer
distance-table computation, and wire it into the SIMD dispatch path. The
kernels are runtime vector-length aware (vsetvl-driven, e32m1) rather than
assuming a fixed vector width.

Changes
- compute_PQ_dis_tables_dsub2<RISCV_RVV> (utils/distances_rvv.cpp): RVV
  kernel for both inner-product and L2 dsub=2 distance tables. Centroids are
  first split into even/odd dimension planes (c0_all / c1_all) so each
  subquantizer's ksub entries load as contiguous vectors; the hot loop
  processes two subquantizers per iteration:
      IP:  r = c0*x0 + c1*x1         (vfmul + vfmacc)
      L2:  r = (c0-x0)^2 + (c1-x1)^2 (vfsub + vfmul + vfadd)
- Add RVV specializations for the accompanying distance primitives:
  fvec_norm_L2sqr, fvec_L2sqr_ny, fvec_L2sqr_ny_transposed and
  fvec_inner_products_ny are vectorized (strided vlse32 over ny); the
  remaining fvec_* and VectorDistance specializations fall back to
  SIMDLevel::NONE.
- compute_distance_tables / compute_inner_prod_tables: route dsub == 2 &&
  nbits >= 3 && nbits < 8 through compute_PQ_dis_tables_dsub2 when
  COMPILE_SIMD_RISCV_RVV is defined, matching the AVX2 / ARM_NEON fast path.
- compute_PQ_dis_tables_dsub2_dispatch: extend the dispatch mask to include
  RISCV_RVV.

### Review fixes

The AI reviewers reported 6 valid defects. This version corrects all of them.

- The RVV `compute_PQ_dis_tables_dsub2` unrolled the subquantizer loop by 2
  and had no tail. An odd `M` left the last `ksub` table entries unwritten.
  The kernel now writes the last subquantizer in a tail loop.
- `fvec_inner_products_ny<RISCV_RVV>` loaded every chunk from `y + j`. The
  base pointer missed the `i * d` offset. Each chunk after the first repeated
  the first vectors. The kernel now loads from `y + i * d + j`.
- `fvec_norm_L2sqr<RISCV_RVV>` ran one `vfredusum` per vector chunk. A
  `d=768` input at VLEN=128 cost 24 serialized reductions. The function again
  keeps a tail-undisturbed accumulator, and it reduces once after the loop.
- The `dsub2` kernels require `ksub % 8 == 0`. The dispatch condition accepted
  every `nbits < 8`, so an `nbits` of 1 or 2 made `compute_distance_tables`
  throw. The condition now also requires `nbits >= 3`. This defect also
  affected the AVX2 and the ARM NEON paths.
- `search_sdc` dropped the per-thread `q_row` pointer precomputation. That
  change added `M` multiplications per database vector on every architecture.
  This version restores the precomputation.
- The `ny` kernels fixed the chunk width at the VLEN=128 value. They now read
  `VLMAX` with `vsetvlmax`. Hardware with a larger VLEN uses the whole vector
  register.

The diff also removes 2 unreadable comments from the RVV kernel.

Caveat: the benchmark below ran on the previous version. That version included
the `search_sdc` change that this version reverts.

### Performance (sg2044)
Cmd: 1M 768 <PQ> 32 100 16 1 1000 1 <metric>  (search ns/query, lower is better)

| #  | CONFIG   | M   | NPROBE | METRIC | F1(ns)  | PQ1(ns) | F2(ns)  | PQ2(ns) | Δ_PQ%   | Δ_F%   |
|----|----------|-----|--------|--------|---------|---------|---------|---------|---------|--------|
| 01 | PQ8x4    | 4   | 8      | L2     | 6656870 | 536988  | 6582134 | 495970  | -7.64%  | -1.12% |
| 02 | PQ8x4    | 4   | 8      | IP     | 6228497 | 475320  | 6164081 | 481118  | +1.22%  | -1.03% |
| 03 | PQ8x8    | 8   | 8      | L2     | 6626171 | 545230  | 6580586 | 500343  | -8.23%  | -0.69% |
| 04 | PQ8x8    | 8   | 8      | IP     | 6228452 | 519710  | 6165707 | 479864  | -7.67%  | -1.01% |
| 05 | PQ8x16   | 16  | 8      | L2     | 6615305 | 697070  | 6582239 | 624059  | -10.47% | -0.50% |
| 06 | PQ8x16   | 16  | 8      | IP     | 6227067 | 642949  | 6164572 | 587271  | -8.66%  | -1.00% |
| 07 | PQ8x32   | 32  | 8      | L2     | 6624323 | 1185655 | 6581329 | 902298  | -23.90% | -0.65% |
| 08 | PQ8x32   | 32  | 8      | IP     | 6239930 | 1155545 | 6164198 | 785403  | -32.03% | -1.21% |
| 09 | PQ8x64   | 64  | 8      | L2     | 6624196 | 2006275 | 6578815 | 1687191 | -15.90% | -0.69% |
| 10 | PQ8x64   | 64  | 8      | IP     | 6225719 | 1800678 | 6164767 | 1512760 | -15.99% | -0.98% |
| 11 | PQ8x96   | 96  | 8      | L2     | 6617525 | 3108377 | 6582946 | 2701758 | -13.08% | -0.52% |
| 12 | PQ8x96   | 96  | 8      | IP     | 6239711 | 2657958 | 6170006 | 2320803 | -12.68% | -1.12% |
| 13 | PQ8x384  | 384 | 8      | L2     | 6620602 | 14029275| 6583820 | 12318712| -12.19% | -0.56% |
| 14 | PQ8x384  | 384 | 8      | IP     | 6228776 | 12089134| 6166143 | 10509785| -13.06% | -1.01% |
| 15 | PQ8x32   | 32  | 4      | L2     | 3321625 | 773663  | 3298874 | 491225  | -36.51% | -0.68% |
| 16 | PQ8x32   | 32  | 16     | L2     |13223858 | 2184906 |13152640 | 1645444 | -24.69% | -0.54% |
| 17 | PQ8x32   | 32  | 32     | L2     |26430233 | 3405062 |26331426 | 3138563 | -7.83%  | -0.37% |
| 18 | PQ8x64   | 64  | 4      | L2     | 3320850 | 1191054 | 3298426 | 969309  | -18.62% | -0.68% |
| 19 | PQ8x64   | 64  | 16     | L2     |13207340 | 3944868 |13149902 | 3252654 | -17.55% | -0.43% |
| 20 | PQ8x64   | 64  | 32     | L2     |26467696 | 6725098 |26335327 | 6667493 | -0.86%  | -0.50% |
| 21 | PQ8x16   | 16  | 4      | L2     | 3316496 | 427653  | 3304051 | 367407  | -14.09% | -0.38% |
| 22 | PQ8x16   | 16  | 16     | L2     |13203491 | 1198002 |13150061 | 1121421 | -6.39%  | -0.40% |
| 23 | PQ8x16   | 16  | 32     | L2     |26438401 | 2199527 |26280149 | 2143973 | -2.53%  | -0.60% |

Δ_PQ%: PQ baseline -> RVV; Δ_F%: Flat baseline -> RVV.

Pull Request resolved: https://github.com/facebookresearch/faiss/pull/5543

Test Plan:
`buck build fbcode//faiss:faiss` passes.

`buck2 test` passes on the PQ and the distance C++ tests:
`test_disable_pq_sdc_tables`, `test_distances_dispatch`, `test_distances_simd`,
`test_ivfpq_codec`, `test_ivfpq_indexing`, `test_pq_encoding` and
`test_pq_code_distance`. The result is 27 pass and 0 fail. The host is aarch64,
so 8 tests skip. Those tests need a second SIMD level to compare against.

This diff adds 2 tests to `faiss/tests/test_product_quantizer.py`:
- `test_dsub2_4bit` covers `dsub=2` with `nbits=4`. `ksub` is 16, so the SIMD
  `dsub2` kernel runs.
- `test_dsub2_2bit` covers `dsub=2` with `nbits=2`. `ksub` is 4, so the generic
  path must run instead.

I checked the `nbits >= 3` fix with a temporary C++ test on ARM NEON. The test
compares `compute_distance_tables` and `compute_inner_prod_tables` against
`compute_distance_table` and `compute_inner_prod_table`, for `nbits` of 1, 2, 3
and 4. The test fails with a `ksub % 8 == 0` assertion before the fix. The test
passes after the fix. I removed the temporary test before submit.

Limits of the local verification:
- I did not run `faiss/tests/test_product_quantizer.py` on this devserver. The
  `fbcode//faiss/python:pyfaiss-py-gen` SWIG step fails on aarch64 with
  `Exec format error`. The failure also reproduces without my changes. CI runs
  the Python tests on x86.
- I did not compile `utils/simd_impl/distances_rvv.cpp`. This devserver has no
  RISC-V toolchain. `xplat.bzl` registers no RVV sources, so Buck and internal
  CI do not build that file either. Only the OSS CMake build on rv64 compiles
  it. A reviewer with RVV hardware should confirm the 4 RVV kernel fixes.

Reviewed By: juancarpio27

Differential Revision: D117914440

Pulled By: mnorris11

fbshipit-source-id: 3539a116d47f4a96c1051bc10e6440deae8c5302
2026-09-24 18:31:50 -07:00

230 lines
7.0 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 numpy as np
import faiss
import unittest
from common_faiss_tests import for_all_simd_levels, NoneSIMDLevel
@for_all_simd_levels
class TestProductQuantizer(unittest.TestCase):
def test_pq(self):
d = 64
n = 2000
cs = 4
np.random.seed(123)
x = np.random.random(size=(n, d)).astype("float32")
pq = faiss.ProductQuantizer(d, cs, 8)
pq.train(x)
codes = pq.compute_codes(x)
x2 = pq.decode(codes)
diff = ((x - x2) ** 2).sum()
# diff= 4418.0562
self.assertGreater(5000, diff)
pq10 = faiss.ProductQuantizer(d, cs, 10)
assert pq10.code_size == 5
pq10.verbose = True
pq10.cp.verbose = True
pq10.train(x)
codes = pq10.compute_codes(x)
x10 = pq10.decode(codes)
diff10 = ((x - x10) ** 2).sum()
self.assertGreater(diff, diff10)
def do_test_codec(self, nbit):
pq = faiss.ProductQuantizer(16, 2, nbit)
# simulate training
rs = np.random.RandomState(123)
centroids = rs.rand(2, 1 << nbit, 8).astype("float32")
faiss.copy_array_to_vector(centroids.ravel(), pq.centroids)
idx = rs.randint(1 << nbit, size=(100, 2))
# can be encoded exactly
x = np.hstack((centroids[0, idx[:, 0]], centroids[1, idx[:, 1]]))
# encode / decode
codes = pq.compute_codes(x)
xr = pq.decode(codes)
assert np.all(xr == x)
# encode w/ external index
assign_index = faiss.IndexFlatL2(8)
pq.assign_index = assign_index
codes2 = np.empty((100, pq.code_size), dtype="uint8")
pq.compute_codes_with_assign_index(
faiss.swig_ptr(x), faiss.swig_ptr(codes2), 100
)
assert np.all(codes == codes2)
def test_codec(self):
for i in range(16):
self.do_test_codec(i + 1)
def test_codes_match_none(self):
"""PQ codes are integer; encode dispatch must produce bit-identical
output at every SIMD level."""
if not faiss.SIMDConfig.is_simd_level_available(faiss.SIMDLevel_NONE):
self.skipTest("SIMDLevel.NONE not available")
d, cs = 64, 4
np.random.seed(123)
x = np.random.random(size=(2000, d)).astype("float32")
for nbits in (4, 6, 8):
with self.subTest(nbits=nbits):
pq = faiss.ProductQuantizer(d, cs, nbits)
pq.train(x)
codes = pq.compute_codes(x)
with NoneSIMDLevel():
codes_none = pq.compute_codes(x)
np.testing.assert_array_equal(codes, codes_none)
@for_all_simd_levels
class TestPQTransposedCentroids(unittest.TestCase):
def do_test(self, d, dsub):
M = d // dsub
pq = faiss.ProductQuantizer(d, M, 8)
xt = faiss.randn((max(1000, pq.ksub * 50), d), 123)
pq.cp.niter = 4 # to avoid timeouts in tests
pq.train(xt)
codes = pq.compute_codes(xt)
# enable transposed centroids table to speedup compute_codes()
pq.sync_transposed_centroids()
codes_transposed = pq.compute_codes(xt)
# disable transposed centroids table
pq.clear_transposed_centroids()
codes_cleared = pq.compute_codes(xt)
assert np.all(codes == codes_transposed)
assert np.all(codes == codes_cleared)
def test_dsub2(self):
self.do_test(16, 2)
def test_dsub5(self):
self.do_test(20, 5)
def test_dsub2_odd(self):
self.do_test(18, 2)
def test_dsub4(self):
self.do_test(32, 4)
def test_dsub4_odd(self):
self.do_test(36, 4)
@for_all_simd_levels
class TestPQTables(unittest.TestCase):
def do_test(self, d, dsub, nbit=8, metric=None):
if metric is None:
self.do_test(d, dsub, nbit, faiss.METRIC_INNER_PRODUCT)
self.do_test(d, dsub, nbit, faiss.METRIC_L2)
return
# faiss.cvar.distance_compute_blas_threshold = 1000000
M = d // dsub
pq = faiss.ProductQuantizer(d, M, nbit)
xt = faiss.randn((max(1000, pq.ksub * 50), d), 123)
pq.cp.niter = 4 # to avoid timeouts in tests
pq.train(xt)
centroids = faiss.vector_to_array(pq.centroids)
centroids = centroids.reshape(pq.M, pq.ksub, pq.dsub)
nx = 100
x = faiss.randn((nx, d), 555)
ref_tab = np.zeros((nx, M, pq.ksub), "float32")
# computation of tables in numpy
for sq in range(M):
i0, i1 = sq * dsub, (sq + 1) * dsub
xsub = x[:, i0:i1]
centsq = centroids[sq, :, :]
if metric == faiss.METRIC_INNER_PRODUCT:
ref_tab[:, sq, :] = xsub @ centsq.T
elif metric == faiss.METRIC_L2:
xsub3 = xsub.reshape(nx, 1, dsub)
cent3 = centsq.reshape(1, pq.ksub, dsub)
ref_tab[:, sq, :] = ((xsub3 - cent3) ** 2).sum(2)
else:
raise AssertionError()
sp = faiss.swig_ptr
new_tab = np.zeros((nx, M, pq.ksub), "float32")
if metric == faiss.METRIC_INNER_PRODUCT:
pq.compute_inner_prod_tables(nx, sp(x), sp(new_tab))
elif metric == faiss.METRIC_L2:
pq.compute_distance_tables(nx, sp(x), sp(new_tab))
else:
raise AssertionError()
# compute sdc tables in numpy
cent1 = np.expand_dims(centroids, axis=2) # [M, ksub, 1, dsub]
cent2 = np.expand_dims(centroids, axis=1) # [M, 1, ksub, dsub]
ref_sdc_tab = ((cent1 - cent2) ** 2).sum(3)
pq.compute_sdc_table()
new_sdc_tab = faiss.vector_to_array(pq.sdc_table)
new_sdc_tab = new_sdc_tab.reshape(M, pq.ksub, pq.ksub)
np.testing.assert_array_almost_equal(ref_tab, new_tab, decimal=5)
np.testing.assert_array_almost_equal(
ref_sdc_tab, new_sdc_tab, decimal=5
)
def test_dsub2(self):
self.do_test(16, 2)
def test_dsub5(self):
self.do_test(20, 5)
def test_dsub2_odd(self):
self.do_test(18, 2)
def test_dsub4(self):
self.do_test(32, 4)
def test_dsub4_odd(self):
self.do_test(36, 4)
# too slow
# def test_12bit(self):
# self.do_test(32, 4, nbit=12)
def test_4bit(self):
self.do_test(32, 4, nbit=4)
def test_dsub2_4bit(self):
# ksub = 16, so the SIMD dsub2 kernels run. M = 8 is even.
self.do_test(16, 2, nbit=4)
def test_dsub2_4bit_odd(self):
# M = 9 is odd, so the dsub2 kernels must also write the last
# subquantizer. test_dsub2_odd cannot cover this case, because
# nbit=8 does not reach the dsub2 kernels.
self.do_test(18, 2, nbit=4)
def test_dsub2_2bit(self):
# ksub = 4 is not a multiple of 8, so the dsub2 kernels must be
# skipped in favour of the generic path
self.do_test(16, 2, nbit=2)