Files
faiss/tests/bench_hnsw_deterministic.py
T
Michael Norris a424dcb809 faiss HNSW: add opt-in deterministic lock-free graph build (faiss::hnsw_deterministic_build) (#5486)
Summary:
Pull Request resolved: https://github.com/facebookresearch/faiss/pull/5486

TLDR: adds deterministic HNSW build inspired by ParlayANN. The deterministic build is reproducible AND faster than the lock-based build at every scale and thread count we measured. This is gated behind `faiss::hnsw_deterministic_build`. We plan to test internally, then make this the default flow and remove the existing flow.
--

https://arxiv.org/abs/2305.04359
--

HOW TO ENABLE
--
```
C++:     faiss::hnsw_deterministic_build = true;
Python:  faiss.cvar.hnsw_deterministic_build = True
```
Nothing else is needed, and nothing changes until you do it: the flag defaults
to false, so this diff is a no-op on its own.

An embedder driving this from its own configuration system should resolve the
value once at startup and assign the global, rather than per index: changing it
between two `add()` calls would leave one index built by each algorithm.

`python/__init__.pyi` now declares `faiss.cvar`. It was missing, so any Python
consumer assigning a faiss global failed Pyre with "No attribute `cvar` in
module `faiss`" -- fixed here rather than with a `pyre-ignore` in each consumer.

WHEN IT IS WORTH ENABLING
--
Only for builds that run multi-threaded. At one thread the existing lock-based
build is *already* deterministic, so the flag buys nothing beyond ~6% build
time. Verified: lock-based at OMP=1 is byte-identical across runs, while at
OMP=8 46% of neighbor slots differ. A consumer that pins
`omp_set_num_threads(1)` should not bother.

similarities to parlayANN:
- add vertices in doubling batches against frozen snapshot
- defer adding reciprocal edges immediately, add them later after parallel phase

differences from ParlayANN:
- re-uses Faiss HNSW pruning in `shrink_neighbor_list`

 ---

AI (with a bunch of edits) explanation in more detail:
--

What changed
- `IndexHNSW::add` and `IndexBinaryHNSW::add` pick the build at runtime. Both paths are compiled in and **the default is unchanged** (lock-based); nothing switches until the flag or the hook says so.
- Turning it on:
```
C++:     faiss::hnsw_deterministic_build = true;
Python:  faiss.cvar.hnsw_deterministic_build = True
```
- A single plain `bool` is the whole API: libfaiss takes no dependency on any configuration system, and an embedder that wants one resolves it itself and assigns the global:
```
faiss.cvar.hnsw_deterministic_build = <your config lookup>
```
- Resolve it once at startup, not per index. `add()` reads the flag each call, so changing it between two `add()` calls on one index would build part of the graph with each algorithm -- reproducible in neither.
- **This flag is transitional.** Once the deterministic build is validated in production, the follow-up makes it the only path and deletes the lock-based build and the flag.
- The deterministic path now supports the CAGRA level-0 import configuration: `init_level0=false` skips the level-0-only bucket (level 0 is supplied by the imported CAGRA graph), and `keep_max_size_level0` fills the base layer to 2*M. So `IndexHNSWCagra` (CPU) and `GpuIndexCagra::copyTo(IndexHNSWCagra*)` build through the deterministic path.
- `IndexBinaryHNSW::add` is gated by the same flag and shares the same deterministic implementation: `hnsw_add_vertices_deterministic` takes `make_distance_computer` / `set_query` callbacks, so the binary index just supplies its own Hamming distance computer. It keeps its own lock-based `hnsw_add_vertices` for the default path.

Background
--
HNSW construction in Faiss was non-deterministic under parallel builds: multiple runs of `IndexHNSW::add` with the same data and seeds could produce different graphs, a problem for persistence, crash recovery, and replication (the ParlayANN motivation, https://arxiv.org/abs/2305.04359). Sources of non-determinism were: (1) the reciprocal-link write race in `add_links_starting_from_impl`; (2) floating-point distance ties resolved in heap/visitation order; (3) the entry-point bootstrap `#pragma omp critical` race.

Algorithm (adapted from ParlayANN to Faiss's level-batched structure):
- Per level bucket (highest first, deterministic shuffle), points are inserted in prefix-doubling sub-batches (batch sizes 1, 2, 4, ... capped at 2% of the index).
- Phase A (`HNSW::compute_forward_links_deterministic`, parallel): each point greedily descends and computes its forward links against the immutable snapshot from the end of the previous sub-batch, writing only its own neighbor slots. Reciprocal-edge requests are collected, not applied, so this phase is race-free.
- Phase B (`HNSW::merge_reverse_links_deterministic`, parallel): reverse edges are grouped by destination with a fixed-size 256-bucket radix partition on the low bits of `dest` (a small constant bucket count, independent of `ntotal` and thread count, so grouping stays O(edges) in memory), each bucket sorted by `(level, dest)` and merged in parallel. Every affected node is merged exactly once in a total order (distance, ties by id) and re-pruned with the same RNG heuristic. Because every `dest` maps to exactly one bucket, distinct nodes touch disjoint slots (no locks) and the merge is order- and thread-count-independent. The Phase-B parallel-for uses `schedule(static)` — the libomp dynamic dispatcher segfaults in some build configs (the pre-existing lock-based build carried the same warning).

Guarantee: the resulting graph is reproducible across runs at a fixed thread count and, in practice, across thread counts (the merge is fully order-independent). Recall matches the previous default at every efSearch.

## Performance: build time (40M, d=128, M=32, efC=64, 166 threads)
10-round interleaved timing study (one deterministic + one lock-based build per round, so both see identical host conditions):
  deterministic per-round s: 285.58 275.03 280.84 272.62 273.73 272.61 272.05 272.90 269.87 272.29
  lock-based    per-round s: 306.23 352.97 322.76 294.23 339.32 303.04 291.46 341.96 359.31 282.92
  deterministic: min=269.87 mean=274.75 median=272.76 max=285.58 std=4.53
  lock-based:    min=282.92 mean=319.42 median=314.50 max=359.31 std=26.12
  det/lock: mean=0.860 (deterministic ~14% faster), median=0.867
The deterministic build is ~14% faster than the lock-based build at 40M and ~6x more stable run-to-run (std 4.53s vs 26.12s), since it does not depend on lock-contention timing. Peak RSS ~66GB vs ~56GB. Recall matches at every efSearch (byte-identical graph across builds).

## Performance: search time

Back on the deterministic HEAD, tree clean. Here's the matched A/B — same 40M synthetic data, same machine (AMD Genoa, 166 cores),
  search_repeat=100, deterministic (my HEAD) vs lock-based (parent commit). Since my diff doesn't touch search() at all, any difference is
  purely graph structure + measurement noise.

  Search QPS: deterministic vs lock-based (40M synthetic, repeat=100)

  HNSW16
```
  ┌──────────┬─────────────────┬─────────┬──────────┬───────┐
  │ efSearch │ recall det/lock │ QPS det │ QPS lock │   Δ   │
  ├──────────┼─────────────────┼─────────┼──────────┼───────┤
  │       64 │ 0.828/0.820     │ 170,329 │  177,995 │ −4.3% │
  ├──────────┼─────────────────┼─────────┼──────────┼───────┤
  │      128 │ 0.866/0.862     │ 112,727 │  110,727 │ +1.8% │
  ├──────────┼─────────────────┼─────────┼──────────┼───────┤
  │      256 │ 0.886/0.888     │  59,815 │   56,784 │ +5.3% │
  └──────────┴─────────────────┴─────────┴──────────┴───────┘
```
  HNSW32
```
  ┌──────────┬─────────────────┬─────────┬──────────┬───────┐
  │ efSearch │ recall det/lock │ QPS det │ QPS lock │   Δ   │
  ├──────────┼─────────────────┼─────────┼──────────┼───────┤
  │       64 │ 0.935/0.930     │ 110,186 │  108,411 │ +1.6% │
  ├──────────┼─────────────────┼─────────┼──────────┼───────┤
  │      128 │ 0.958/0.953     │  68,019 │   66,308 │ +2.6% │
  ├──────────┼─────────────────┼─────────┼──────────┼───────┤
  │      256 │ 0.965/0.960     │  37,624 │   35,828 │ +5.0% │
  └──────────┴─────────────────┴─────────┴──────────┴───────┘
```
  HNSW32,SQ8
```
  ┌──────────┬─────────────────┬─────────┬──────────┬────────┐
  │ efSearch │ recall det/lock │ QPS det │ QPS lock │   Δ    │
  ├──────────┼─────────────────┼─────────┼──────────┼────────┤
  │       64 │ 0.926/0.934     │ 220,713 │  198,325 │ +11.3% │
  ├──────────┼─────────────────┼─────────┼──────────┼────────┤
  │      128 │ 0.948/0.953     │ 117,504 │  129,173 │  −9.0% │
  ├──────────┼─────────────────┼─────────┼──────────┼────────┤
  │      256 │ 0.961/0.963     │  58,582 │   65,551 │ −10.6% │
  └──────────┴─────────────────┴─────────┴──────────┴────────┘
```
(Low-ef points ef16/32 omitted from the verdict — even at 100 repeats their std is ~8–20%, too noisy; ef128/256 std is ~3–5%.)

Verdict: no search-QPS regression
- Pure HNSW (16, 32): QPS at parity — within ±5%, and actually slightly faster deterministic at the high-recall points (ef128/256), with
  equal-or-better recall.
- HNSW32,SQ8: more scatter (±10%, mixed direction) — but it tracks small correlated recall differences (det ef256 is 0.961 vs 0.963),
  i.e. the two different graphs sit at slightly different recall/QPS operating points, not a systematic slowdown. Search code is
  identical, so this is graph-structure + noise, not a code regression. If you want it pinned down, a recall-matched (interpolated)
  comparison would remove the operating-point confound.
- Bonus: the deterministic build was 2–3× faster in every case (e.g. HNSW32: 277 s vs 527 s; HNSW16: 164 s vs 429 s) — consistent with
  all prior results.

## Single-threaded (OMP_NUM_THREADS=1)
Customers frequently build with OMP=1 or OpenMP disabled, so this case matters. Measured at 1M / d=128 / M=32 / efC=64, single-threaded:
  build time: lock-based 188.36s vs deterministic 176.40s  (0.94x -> deterministic ~6% FASTER)
  peak RSS:   1.6 GB (both, identical)
  recall@10 ef 16/32/64/128: lock-based .8830/.9387/.9676/.9853 vs deterministic .8832/.9381/.9625/.9798
No single-threaded regression: the deterministic build is slightly faster (it avoids the per-node OpenMP lock ops), uses the same memory, and matches recall within noise. Note the lock-based build was already deterministic at a single thread, so single-threaded users lose nothing and gain a small speedup.

## Serialization compatibility
No on-disk format change, verified in `index_read.cpp` / `index_write.cpp`:
- `hnsw_deterministic_build` is a namespace-scope global, not a field on `HNSW` or `IndexHNSW`, so it is not part of any serialized struct. Like `retain_locks`, it is a pure runtime build flag.
- `write_HNSW` / `read_HNSW` and the `IndexHNSW` field layout are unchanged. The subtype fourcc tags, header, CAGRA block, graph CSR (entry_point / max_level / levels / offsets / neighbors / efC / efS), and storage are all as before.
- `keep_max_size_level0` is still serialized only for the CAGRA subtype (`IHc2`/`IHNc`); `init_level0` is build-only (not serialized).
- The deterministic build emits the same HNSW CSR structure (only neighbor content differs), so old indexes read unchanged and new indexes remain readable by older Faiss.
- Verified by the `io_and_retest` serialize -> deserialize -> re-search round-trips in `test_graph_based.py` / `test_hnsw.cpp` (all pass).
- Measured on a 1M index: written with the flag off, flag flipped on, read back
  -> `neighbors`, `offsets`, `entry_point`, `max_level` byte-identical and
  search ids+distances identical. The flag is read only inside `add()`.
- Mixed-mode append (lock-built graph extended by a deterministic `add()`) was
  measured against brute-force ground truth at 220k and is indistinguishable
  from either pure build -- recall@10 within +-0.02 of both at ef 32/64/128. No
  existing test covers this, since every test builds one way in one process.

## CAGRA API for HNSW build on multi-GPU (aka D106837134) — MAST verification
Verified end-to-end on MAST (8x H100 Grand Teton, Approach D, 100M vectors) with this change in the build — the multi-GPU CAGRA -> HNSW graph-build time is comparable to the D106837134 baseline (no regression):
  all_neighbors build: 367.4s   optimize: 231.7s   copyTo: 18.4s   serialize: 28.9s (66 GB)
  INDEX build -> serialize total: 661.7s (11.0 min)   [D106837134 baseline: 721s]
  recall@10 (tiled 100M): ef64 0.7746, ef128 0.8830, ef256 0.9429
- This confirms this CPU-side change builds, links, and runs in the GPU CAGRA binary at scale and does not regress the pipeline. Note the Approach-D run uses copyTo(base_level_only=True), which imports the CAGRA graph directly as HNSW level 0 and skips add(), so it does not itself route through the deterministic add().
- The deterministic CAGRA level-0 import this change adds (the copyTo path with base_level_only=False: init_level0=false skips the level-0 bucket; keep_max_size_level0 fills the base layer) is covered by passing unit tests: `Test_IndexHNSWCagra_BaseLevelOnly_RangeSearch` (C++), `test_hnsw_no_init_level0`, and `test_hnsw_cagra_IP` / `_base_level_only` (Python).

## Behavioral note: level-0 base layer under keep_max_size_level0 (reviewers, please note)
One deliberate difference from the lock-based build, in the CAGRA base-layer case only: the old build gated the "fill the level-0 list up to 2*M" behavior on the inserted point's OWN top level (`keep_max_size_level0 && pt_level == 0`), so a level>=1 node's level-0 list could be pruned below 2*M. The deterministic build gates on the LINK level (`keep_max_size_level0 && level == 0`), so EVERY node's level-0 list is filled to 2*M when `keep_max_size_level0` is set (not only the level-0-only points). This is a strict superset of the old coverage -- it fills exactly to the 2*M slot capacity (no overflow) and yields a fuller/denser base layer for CPU `IndexHNSWCagra`, which is what `GpuIndexCagra::copyFrom(IndexHNSWCagra*)` reads back. It is INERT for the default build (`keep_max_size_level0` defaults to false, so the gate is never true) and never affects a non-CAGRA graph. Called out explicitly so reviewers know the CPU `IndexHNSWCagra` base-layer graph is intentionally denser than the pre-diff build; worth a sanity check against GPU `copyFrom` expectations.

Reviewed By: pankajsingh88

Differential Revision: D112025877

fbshipit-source-id: dd111a5a3674c08200905bfea1d056f0fa5b3d42
2026-08-10 14:32:47 -07:00

275 lines
8.4 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.
# Microbenchmark for the HNSW graph build (deterministic, lock-free). Two modes:
#
# summary (default): build the index FAISS_DET_NBUILDS times, reporting build
# time (min/mean) + peak RSS, graph reproducibility (blake2b of
# neighbors+offsets across builds), and the recall/QPS tradeoff vs
# brute-force ground truth.
#
# timing study (FAISS_DET_ROUNDS > 0): time the build FAISS_DET_ROUNDS times,
# reporting per-round times and min/mean/median/max/std.
#
# Memory: a single HNSWFlat index at large nb is tens of GB, so only one index
# is alive at a time, the dataset is serialized once and memory-mapped
# (file-backed pages are evictable instead of hitting swap), and ground truth
# uses faiss.knn (no second full-size index copy).
#
# Env (all optional): FAISS_DET_NB (default 40_000_000), FAISS_DET_NBUILDS (3),
# FAISS_DET_ROUNDS (0), FAISS_DET_M (32), FAISS_DET_EFC (64), FAISS_DET_NQ
# (1_000), FAISS_DET_SEED (1234), FAISS_DET_DATA_DIR (a tempdir, reused across
# runs), FAISS_DET_GRAPH_OUT (dump last graph), FAISS_DET_TAG.
#
# Run with:
# buck2 run fbcode//faiss/tests:bench_hnsw_deterministic
from __future__ import annotations
import gc
import hashlib
import os
import tempfile
import threading
import time
import unittest
import faiss
import numpy as np
from faiss.contrib.datasets import SyntheticDataset
def _envint(name: str, default: int) -> int:
v = os.environ.get(name)
return int(v) if v else default
class RSSPeak:
# Samples VmRSS in a daemon thread; reset() rebases the peak to "now" so the
# peak during a single build interval can be captured.
def __init__(self, interval: float = 0.25) -> None:
self._peak = 0
self._stop = False
self._lock = threading.Lock()
self._interval = interval
self._t = threading.Thread(target=self._run, daemon=True)
@staticmethod
def _rss_kb() -> int:
with open("/proc/self/status") as f:
for line in f:
if line.startswith("VmRSS:"):
return int(line.split()[1])
return 0
def _run(self) -> None:
while not self._stop:
r = self._rss_kb()
with self._lock:
self._peak = max(self._peak, r)
time.sleep(self._interval)
def start(self) -> None:
self._t.start()
def reset(self) -> None:
with self._lock:
self._peak = self._rss_kb()
def peak_gb(self) -> float:
with self._lock:
return self._peak / 1048576.0
def stop(self) -> None:
self._stop = True
# Join the sampler so no in-flight `self._peak = max(...)` update can
# race a subsequent peak_gb() read.
self._t.join()
def dataset_paths(d: int, nb: int, nq: int, seed: int) -> tuple[str, str]:
base = os.environ.get("FAISS_DET_DATA_DIR") or os.path.join(
tempfile.gettempdir(), "faiss_det_bench"
)
os.makedirs(base, exist_ok=True)
tag = f"d{d}_nb{nb}_nq{nq}_s{seed}"
return (
os.path.join(base, f"xb_{tag}.npy"),
os.path.join(base, f"xq_{tag}.npy"),
)
def make_dataset(d: int, nb: int, nq: int, seed: int):
# SyntheticDataset: a low-intrinsic-dimensional ellipsoid so recall is
# meaningful; HNSWFlat needs no training (nt=0). Serialized once and
# returned as a copy-on-write memmap so its pages stay file-backed (a build
# still makes its own resident copy, but the source can be evicted under
# pressure rather than swapped). Queries are tiny and kept in memory.
xb_path, xq_path = dataset_paths(d, nb, nq, seed)
if not (os.path.exists(xb_path) and os.path.exists(xq_path)):
ds = SyntheticDataset(d, 0, nb, nq, seed=seed)
np.save(xb_path, ds.get_database())
np.save(xq_path, ds.get_queries())
del ds
gc.collect()
xb = np.load(xb_path, mmap_mode="c")
xq = np.load(xq_path)
return xb, xq
def ground_truth(xb, xq, k):
# Block-wise brute force: streams over xb without a second full-size copy.
_, gt = faiss.knn(xq, xb, k, metric=faiss.METRIC_L2)
return gt
def dump_graph(index, path):
neighbors = faiss.vector_to_array(index.hnsw.neighbors)
offsets = faiss.vector_to_array(index.hnsw.offsets)
with open(path, "wb") as f:
f.write(neighbors.tobytes())
f.write(offsets.tobytes())
return neighbors.nbytes + offsets.nbytes
def build_index(xb, d, M, efc, rss):
index = faiss.IndexHNSWFlat(d, M)
index.hnsw.efConstruction = efc
rss.reset()
t0 = time.perf_counter()
index.add(xb)
return index, time.perf_counter() - t0, rss.peak_gb()
def recall_at_k(index, xq, gt, k, efSearch):
index.hnsw.efSearch = efSearch
t0 = time.perf_counter()
_, ids = index.search(xq, k)
dt = time.perf_counter() - t0
nq = xq.shape[0]
hits = 0
for i in range(nq):
hits += len(set(ids[i]).intersection(set(gt[i])))
return hits / (nq * k), nq / dt
def graph_hash(index) -> str:
# blake2b of neighbors+offsets, no copy beyond vector_to_array.
h = hashlib.blake2b()
neighbors = faiss.vector_to_array(index.hnsw.neighbors)
h.update(memoryview(neighbors))
del neighbors
offsets = faiss.vector_to_array(index.hnsw.offsets)
h.update(memoryview(offsets))
return h.hexdigest()
def _stats(xs):
xs2 = sorted(xs)
n = len(xs2)
mean = sum(xs2) / n
median = xs2[n // 2] if n % 2 else (xs2[n // 2 - 1] + xs2[n // 2]) / 2
std = (sum((x - mean) ** 2 for x in xs2) / n) ** 0.5
return min(xs2), mean, median, max(xs2), std
EF_SEARCH = (16, 32, 64, 128)
def summary(xb, xq, gt, d, nb, M, efc, k, nbuilds, rss, threads):
print(
f"\n=== d={d} nb={nb:,} M={M} efC={efc} k={k} nbuilds={nbuilds} "
f"(threads={threads}) ==="
)
times, peaks, hashes = [], [], []
idx = None
for _ in range(nbuilds):
if idx is not None:
del idx
gc.collect()
idx, t, peak = build_index(xb, d, M, efc, rss)
times.append(t)
peaks.append(peak)
hashes.append(graph_hash(idx))
out = os.environ.get("FAISS_DET_GRAPH_OUT")
if out:
dump_graph(idx, out)
tmin, tmean = min(times), sum(times) / len(times)
print(
f"build time: min={tmin:7.2f}s mean={tmean:7.2f}s "
f"peak={max(peaks):.1f}GB"
)
print(
f"graph reproducible across {nbuilds} builds: "
f"{'YES' if len(set(hashes)) == 1 else 'NO'}"
)
print(f"{'efSearch':>9} | {'recall':>8} {'QPS':>9}")
for efs in EF_SEARCH:
r, q = recall_at_k(idx, xq, gt, k, efs)
print(f"{efs:>9} | {r:>8.4f} {q:>9.0f}")
del idx
gc.collect()
def timing_study(xb, d, M, efc, rounds, rss, tag, threads):
print(
f"\n=== timing study: {rounds} rounds, threads={threads}, "
f"tag={tag!r} ==="
)
ts = []
for r in range(rounds):
idx, t, peak = build_index(xb, d, M, efc, rss)
ts.append(t)
del idx
gc.collect()
print(f"round {r + 1:>2}/{rounds} {t:7.2f}s peak={peak:.1f}GB")
mn, mean, med, mx, std = _stats(ts)
print("build s: " + " ".join(f"{x:.2f}" for x in ts))
print(
f"build: min={mn:.2f} mean={mean:.2f} median={med:.2f} "
f"max={mx:.2f} std={std:.2f}"
)
def main() -> None:
d = 128
nb = _envint("FAISS_DET_NB", 40_000_000)
nq = _envint("FAISS_DET_NQ", 1_000)
M = _envint("FAISS_DET_M", 32)
efc = _envint("FAISS_DET_EFC", 64)
seed = _envint("FAISS_DET_SEED", 1234)
nbuilds = _envint("FAISS_DET_NBUILDS", 3)
rounds = _envint("FAISS_DET_ROUNDS", 0)
k = 10
tag = os.environ.get("FAISS_DET_TAG", "")
# Fixed thread count: the build guarantees reproducibility at a fixed
# thread count.
faiss.omp_set_num_threads(faiss.omp_get_max_threads())
threads = faiss.omp_get_max_threads()
faiss.cvar.hnsw_deterministic_build = True
print("faiss HNSW deterministic-build benchmark")
rss = RSSPeak()
rss.start()
xb, xq = make_dataset(d, nb, nq, seed)
if rounds > 0:
timing_study(xb, d, M, efc, rounds, rss, tag, threads)
else:
gt = ground_truth(xb, xq, k)
summary(xb, xq, gt, d, nb, M, efc, k, nbuilds, rss, threads)
rss.stop()
class BenchHNSWDeterministic(unittest.TestCase):
def test_bench(self) -> None:
main()
if __name__ == "__main__":
main()