Files
faiss/benchs/bench_rabitq.py
lyang24 e6bd1d252c Add AVX512 VPOPCNT dynamic dispatch level (#5531)
Summary:
## What

  Split the existing VPOPCNTDQ RaBitQ and Hamming kernels from the full Sapphire Rapids SIMD level.

  DD builds now expose an `AVX512_VPOPCNT` capability for CPUs such as Ice Lake and Zen 4. The fallback chain is:

  `AVX512_SPR -> AVX512_VPOPCNT -> AVX512 -> AVX2 -> NONE`

  The ordinary static AVX512 target remains unchanged and contains no VPOPCNT instructions.

  ## Why

  The VPOPCNT kernels only require baseline AVX-512 plus `AVX512_VPOPCNTDQ`. Tying them to the SPR level unnecessarily excluded CPUs that support VPOPCNTDQ but not AVX512-FP16, notably Zen 4.

  ## Changes made while importing

  Two changes were needed on top of the pull request.

  **Feature detection read the wrong CPUID leaf.** `AVX512_VPOPCNTDQ` and `AVX512_VNNI` were read from leaf 1 ECX. Both live in leaf 7 subleaf 0 ECX, at bits 14 and 11. Leaf 1 ECX holds unrelated bits at those positions, so the new level was never selected on any CPU. Measured on an AMD Genoa host before the fix: the level reported as unavailable and detection returned `AVX512`. After the fix the same host reports `AVX512_VPOPCNT`.

  **The build definitions outside CMake were not updated.** The pull request renames three files. The other build description still named the old paths and had no entry for the new level, so the library did not build. This adds the level constant, its compiler flags, its `COMPILE_SIMD_AVX512_VPOPCNT` define, and the renamed paths.

**The new level was not registered in the compiled-level mask.** `compiled_simd_levels()` reports which levels a binary holds, and `FAISS_SIMD_LEVEL` walks down from the requested level until it finds one that is present. The new level had no entry, so the override always stepped past it to `AVX512` and printed that the level was not compiled in. This was reported by `test_dispatch_with_env_var` on the open-source dynamic-dispatch build.

  **The test helper inferred available levels from `/proc/cpuinfo`.** A CPU feature does not imply that a level is compiled into the build, so the helper listed a level the override then refused. It now asks `SIMDConfig::is_simd_level_available()`, which reports compiled and supported. That is a subset of compiled, so every level it returns is one the override accepts.

  The mask and helper names also follow the naming used in the parent diff, so `AVAILABLE_SIMD_LEVELS_BASE_WITH_VPOPCNT` and `with_simd_level_with_vpopcnt`.

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

Test Plan:
```
buck2 test fbcode//mode/opt -c faiss.dynamic_dispatch=true \
  fbcode//faiss/tests:test_simd_levels fbcode//faiss/tests:test_rabitq_simd
```
Result: all pass.

The build failures the imported version reported are cleared. All three configurations build:
```
buck2 build fbcode//mode/dev fbcode//faiss:faiss fbcode//faiss:faiss_no_multithreading fbcode//faiss:faiss_omp_mock
buck2 build fbcode//mode/opt -c faiss.dynamic_dispatch=true <same three targets>
buck2 build fbcode//mode/dev -c fbcode.arch=aarch64 <same three targets>
```

Detection on an AMD Genoa host, which has AVX512_VPOPCNTDQ and AVX512_BF16 but not AVX512_FP16:

| | before the CPUID fix | after |
| --- | --- | --- |
| detected level | `AVX512` | `AVX512_VPOPCNT` |
| `AVX512_VPOPCNT` available | 0 | 1 |
| `AVX512_SPR` available | 0 | 0 |

Reviewed By: alibeklfc

Differential Revision: D118969754

Pulled By: mnorris11

fbshipit-source-id: d1ca52b614829a3d4b322c9fecf0c004f2174dab
2026-09-10 09:32:36 -07:00

405 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env -S grimaldi --kernel faiss
# 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.
# fmt: off
# flake8: noqa
""":py"""
import statistics
import timeit
from collections import defaultdict
import faiss
from faiss.contrib.datasets import SyntheticDataset
""":py"""
# Dimensions to sweep. The rabitq SIMD kernel
# (bitwise_and_dot_product / bitwise_xor_dot_product / popcount) selects
# its widest tier based on size = d / 8 bytes:
# d=256 -> size=32 -> 256-bit ymm only (no 512-bit work)
# d=512 -> size=64 -> 512-bit zmm, 1 iteration per bit-plane
# d=768 -> size=96 -> 512-bit zmm + 256-bit tail
# d=1024 -> size=128 -> 512-bit zmm only, 2 iterations per bit-plane
# Sweeping these is useful for verifying the AVX512_VPOPCNT specialization in
# faiss/utils/simd_impl/rabitq_avx512_vpopcnt.cpp and for
# profiling perf-record annotations across SIMD-width tiers.
DIMENSIONS = [256, 512, 768, 1024]
nlist: int = 1000
qb: int = 8
# Number of independent timing samples to take per (index, k, nprobe)
# combination. Each sample is itself an average over `trials=10` calls
# inside timeit, so total searches per row = ITERATIONS * 10. Using 3
# samples is enough to flag whether differences across dimensions are
# noise or real, while keeping the bench cheap.
ITERATIONS: int = 3
# This will contain <"index name", ([recalls],[speeds],[labels (the k)])>
recall_speed_data = defaultdict(lambda: [[], [], []])
# This will contain <"index name", ([recalls],[memory for this index])>
recall_memory_data = defaultdict(lambda: [[], []])
# Set when entering each per-d block below; used by helpers that close
# over the active dataset.
ds: SyntheticDataset = None # type: ignore
""":py"""
# Helpers
def trials(index, xq, k):
trials = 10
result = timeit.timeit(
stmt="index.search(xq, k)",
number=trials,
globals={"index": index, "xq": xq, "k": k},
)
return result / trials * 1000.0 # ms
def trials_ivf(index, xq, k, params=None):
trials = 10
result = timeit.timeit(
stmt="search_with_parameters(index, xq, k, params)",
number=trials,
globals={
"search_with_parameters": faiss.search_with_parameters,
"index": index,
"xq": xq,
"k": k,
"params": params,
},
)
return result / trials * 1000.0 # ms
def compute_recall(ground_truth_I, predicted_I):
n_queries, k = ground_truth_I.shape
intersection = faiss.eval_intersection(ground_truth_I, predicted_I)
recall = intersection / (n_queries * k)
return recall
def repeated_trials(trials_fn, *args, n=ITERATIONS, **kwargs):
"""Run a trials function n times and return the list of per-iteration
average speeds (each in ms). Each call to trials_fn is itself an
average over multiple back-to-back searches, so the returned list
contains n independent samples of that average.
"""
return [trials_fn(*args, **kwargs) for _ in range(n)]
def summarize(samples):
"""Return (mean, median, stdev) over a list of timing samples in ms.
stdev is the sample standard deviation (n-1); returns 0.0 for n==1
since stdev is undefined.
"""
mean = statistics.mean(samples)
median = statistics.median(samples)
stdev = statistics.stdev(samples) if len(samples) > 1 else 0.0
return mean, median, stdev
def fmt_speed(samples):
"""Format a list of timing samples as 'mean=X median=Y stdev=Z'."""
mean, median, stdev = summarize(samples)
return f"mean={mean:.1f}ms median={median:.1f}ms stdev={stdev:.2f}ms"
def create_index(ds, factory_string):
index = faiss.index_factory(ds.d, factory_string)
index.train(ds.get_train())
index.add(ds.get_database())
return index
# pyre-ignore
def handle_index(prefix, index, ds, mem, k):
gt_I = ds.get_groundtruth(k)
_, I_res = index.search(ds.get_queries(), k)
speed_samples = repeated_trials(trials, index, ds.get_queries(), k)
mean_speed, _, _ = summarize(speed_samples)
recall = compute_recall(gt_I, I_res)
print(
f"{prefix} recall@{k}: {recall}. Speed: {fmt_speed(speed_samples)}. Memory: {mem/1e6:.3f}MB"
)
recall_speed_data[prefix][0].append(recall)
recall_speed_data[prefix][1].append(mean_speed)
recall_speed_data[prefix][2].append(f"k={k}")
recall_memory_data[prefix][0].append(recall)
recall_memory_data[prefix][1].append(mem)
# pyre-ignore
def handle_ivf_index(prefix, index, ds, mem, k, params):
gt_I = ds.get_groundtruth(k)
for nprobe in 4, 16, 32:
params.nprobe = nprobe
_, I_res = faiss.search_with_parameters(index, ds.get_queries(), k, params)
speed_samples = repeated_trials(
trials_ivf, index, ds.get_queries(), k, params
)
mean_speed, _, _ = summarize(speed_samples)
recall = compute_recall(gt_I, I_res)
print(
f"{prefix} nprobe={nprobe}: recall@{k}: {recall}. Speed: {fmt_speed(speed_samples)}. Memory: {mem/1e6:.3f}MB"
)
recall_speed_data[prefix][0].append(recall)
recall_speed_data[prefix][1].append(mean_speed)
recall_speed_data[prefix][2].append(f"k={k}, nprobe={nprobe}")
recall_memory_data[prefix][0].append(recall)
recall_memory_data[prefix][1].append(mem)
# pyre-ignore
def vary_k_nprobe_measuring_recall_and_memory(prefix, index, ds, mem):
classname = type(index).__name__
for k in (100,):
if classname in [
"IndexRaBitQ",
"IndexPQFastScan",
"IndexHNSWFlat",
"IndexScalarQuantizer",
]:
handle_index(prefix, index, ds, mem, k)
elif classname in [
"IndexIVFRaBitQ",
"IndexPreTransform",
"IndexIVFPQFastScan",
"IndexIVFScalarQuantizer",
]:
if (
classname == "IndexIVFPQFastScan"
or classname == "IndexIVFScalarQuantizer"
):
params = faiss.IVFSearchParameters()
else:
params = faiss.IVFRaBitQSearchParameters()
params.qb = qb
handle_ivf_index(prefix, index, ds, mem, k, params)
""":py '605360559215064'"""
# RaBitQ kernels swept across dimensions. Each iteration rebuilds the
# dataset and the three rabitq index variants. Suffix _d{d} on the
# result key keeps the per-dimension series distinct in the plots.
for d in DIMENSIONS:
print(f"\n========== d={d} ==========")
# Dataset sized to keep the full 4-dimension sweep under ~10 minutes.
# nq=1k is enough for stable timeit averages across 10 trials; nb=200k
# keeps groundtruth (brute-force knn over xb) tractable at d=1024;
# nt=100k still satisfies the IVF k-means training-points floor for
# nlist=1000 (39 × 1000 = 39k minimum).
ds = SyntheticDataset(d, 100_000, 200_000, 1_000)
# IndexRaBitQ
fac_s = "RaBitQ"
non_ivf_rbq = faiss.index_factory(ds.d, fac_s)
non_ivf_rbq.qb = qb
non_ivf_rbq.train(ds.get_train())
non_ivf_rbq.add(ds.get_database())
mem = non_ivf_rbq.code_size * non_ivf_rbq.ntotal
vary_k_nprobe_measuring_recall_and_memory(f"{fac_s}_d{d}", non_ivf_rbq, ds, mem)
del non_ivf_rbq
# IndexIVFRaBitQ with no random rotation
fac_s = f"IVF{nlist},RaBitQ"
rbq1 = faiss.index_factory(ds.d, fac_s)
rbq1.qb = qb
rbq1.train(ds.get_train())
rbq1.add(ds.get_database())
mem = rbq1.code_size * rbq1.ntotal
vary_k_nprobe_measuring_recall_and_memory(f"{fac_s}_d{d}", rbq1, ds, mem)
del rbq1
# IndexIVFRaBitQ with random rotation
fac_s = f"IVF{nlist},RaBitQ"
rbq2 = faiss.index_factory(ds.d, fac_s)
rbq2.qb = qb
rrot = faiss.RandomRotationMatrix(ds.d, ds.d)
rrot.init(123)
index_pt = faiss.IndexPreTransform(rrot, rbq2)
index_pt.train(ds.get_train())
index_pt.add(ds.get_database())
mem = rbq2.code_size * index_pt.ntotal
vary_k_nprobe_measuring_recall_and_memory(
f"{fac_s}_RROT_d{d}", index_pt, ds, mem
)
del index_pt
""":py '644702398382829'"""
# Non-rabitq baselines (SQ, PQfs, HNSW) below. These don't exercise the
# rabitq SIMD kernels, so we don't sweep dimensions for them; instead
# we pick one dimension and build them once. Change BASELINE_D if you
# want a different working point, or comment out the cells below if
# you only care about the rabitq sweep.
BASELINE_D = 256
ds = SyntheticDataset(BASELINE_D, 100_000, 200_000, 1_000)
# IndexScalarQuantizer
for M in [4, 6, 8]:
fac_s = f"SQ{M}"
sq = create_index(ds, fac_s)
mem = sq.code_size * sq.ntotal
vary_k_nprobe_measuring_recall_and_memory("Index" + fac_s, sq, ds, mem)
""":py '1347502839702520'"""
# IndexIVFScalarQuantizer
for M in [4, 6]: # 8 seems to have no recall improvement in this dataset.
fac_s = f"IVF{nlist},SQ{M}"
sq = create_index(ds, fac_s)
mem = sq.code_size * sq.ntotal
vary_k_nprobe_measuring_recall_and_memory(fac_s, sq, ds, mem)
""":py '1350039419637535'"""
# PQFS
for m in [32, 64, 128]:
fac_s = f"PQ{m}x4fs"
pqfs = create_index(ds, fac_s)
mem = pqfs.code_size * pqfs.ntotal
vary_k_nprobe_measuring_recall_and_memory(fac_s, pqfs, ds, mem)
del pqfs
""":py '2549074352105737'"""
# IVFPQFS
for m in [32, 64, 128]:
fac_s = f"IVF{nlist},PQ{m}x4fs"
ivf_pqfs = create_index(ds, fac_s)
mem = ivf_pqfs.code_size * ivf_pqfs.ntotal
vary_k_nprobe_measuring_recall_and_memory(fac_s, ivf_pqfs, ds, mem)
del ivf_pqfs
""":py '3933359133572530'"""
# HNSW
for m in [8, 16, 32]:
fac_s = f"HNSW{m}"
index = create_index(ds, fac_s)
storage = faiss.downcast_index(index.storage)
mem = (
storage.ntotal * storage.code_size
+ index.hnsw.neighbors.size() * 4
+ index.hnsw.offsets.size() * 8
)
vary_k_nprobe_measuring_recall_and_memory(fac_s, index, ds, mem)
del index
""":py"""
import matplotlib.pyplot as plt
from adjustText import adjust_text
# Specific colors that stand out against each other for this many data points.
colors = [
"black",
"darkgray",
"darkred",
"red",
"orange",
"wheat",
"olive",
"yellow",
"lime",
"teal",
"cyan",
"skyblue",
"royalblue",
"navy",
"darkviolet",
"fuchsia",
"deeppink",
"pink",
]
""":py '1023372579245229'"""
slowest_speed = 0.0
for key, vals in recall_speed_data.items():
for speed in vals[1]:
slowest_speed = max(slowest_speed, speed)
plt.axis([0, 1.0, 0, slowest_speed + 100.0]) # [xmin, xmax, ymin, ymax]
for i, (key, vals) in enumerate(recall_speed_data.items()):
recalls = vals[0]
speeds = vals[1]
plt.plot(
recalls,
speeds,
linestyle=" ",
marker="o",
color=colors[i % len(colors)],
label=key,
markersize=15,
)
# Adding k and nprobe labels makes the diagram very busy, but can be enabled by uncommenting the following lines:
# ks = vals[2]
# texts = []
# for i, (x_val, y_val) in enumerate(zip(recalls, speeds)):
# texts.append(plt.text(x_val, y_val, ks[i]))
# # Adjust text labels
# adjust_text(
# texts,
# arrowprops=dict(arrowstyle="-", color="black", lw=0.5),
# force_text=(0.1, 0.25),
# force_points=(0.2, 0.5),
# only_move={"points": "xy"},
# )
plt.title("Recall vs Speed")
plt.xlabel("Recall")
plt.ylabel("Speed")
plt.legend()
plt.show()
""":py '1354989919068149'"""
largest_mem = 0.0
for key, vals in recall_memory_data.items():
for mem in vals[1]:
largest_mem = max(largest_mem, mem)
plt.ylim(1e6, 1e10)
plt.yscale("log", base=10)
for i, (key, vals) in enumerate(recall_memory_data.items()):
recalls = vals[0]
mems = vals[1]
plt.plot(
recalls,
mems,
linestyle=" ",
marker="o",
color=colors[i % len(colors)],
label=key,
markersize=10,
)
texts = []
if i == 0:
for j in range(min(2, len(recalls))):
texts.append(plt.text(recalls[j], mems[j], "RaBitQ"))
adjust_text(
texts,
arrowprops=dict(arrowstyle="-", color="black", lw=0.5),
force_text=(0.5, 0.25),
force_points=(1.0, 1.5),
expand_points=(5.0, 10.0),
)
plt.title("Recall vs Memory")
plt.xlabel("Recall")
plt.ylabel("Memory")
plt.legend()
plt.show()
""":py"""