Skip to content

GPU HNSW Support - #5459

Open
premal wants to merge 10 commits into
facebookresearch:mainfrom
6si:gpu-hnsw
Open

GPU HNSW Support#5459
premal wants to merge 10 commits into
facebookresearch:mainfrom
6si:gpu-hnsw

Conversation

@premal

@premal premal commented Jul 23, 2026

Copy link
Copy Markdown

Summary

Adds GpuIndexHNSW — a GPU search backend for the classic faiss::IndexHNSW graph. You build a normal CPU HNSW index (IndexHNSWFlat or IndexHNSWSQ), then move it to the GPU with the standard index_cpu_to_gpu cloner; the cloner uploads the graph + vectors to the device and returns a GpuIndexHNSW for search. There is no GPU build path — it reuses the graph the user already built (zero rebuild cost) and runs beam search on the GPU to exploit device memory bandwidth.

Discussion / motivation: #5458.

Why not CAGRA: on our workload (INT8_VECTOR, dim 384, COSINE, ~538M rows) GPU CAGRA (cuVS) returned 0% recall (R@1/R@5/R@10 = 0.000), so it was not usable for us; CPU HNSW plateaus at ~1,600 vec/s on 10 nodes (DRAM-bandwidth bound). This gives the existing HNSW graph a GPU search path instead.

What's in this PR

API (follows existing faiss GPU index conventions)

  • GpuIndexHNSW(GpuResourcesProvider*, int dims, MetricType, GpuIndexHNSWConfig) — empty ctor, mirroring GpuIndexFlat.
  • GpuIndexHNSW(GpuResourcesProvider*, const faiss::IndexHNSW*, GpuIndexHNSWConfig) — convenience ctor that calls copyFrom.
  • copyFrom(const faiss::IndexHNSW*) — uploads a CPU-built index to the GPU.
  • copyTo(...) / index_gpu_to_cpu()throw a FaissException: the index is search-only (uploads a CPU-built graph; no reconstructable CPU copy retained).
  • GpuCloner: index_cpu_to_gpu routes IndexHNSWFlat / IndexHNSWSQ to GpuIndexHNSW; IndexHNSWCagra is excluded (kept on the cuVS/CAGRA path).
  • SWIG bindings: header included; internal searchHost / searchHostInt8 / setSearchParams %ignored.
  • GpuAutoTune: SearchParametersGpuHNSW.ef sweep, mirroring CPU HNSW efSearch.

Storage — native low precision (no decode-to-fp32)

Storage Device layout B/elem Distance kernel
IndexHNSWFlat (fp32) native fp32 4 fp32 accumulation
IndexHNSWSQ QT_8bit_direct_signed native int8 1 DP4A (int8×int8→int32)
IndexHNSWSQ QT_fp16 native fp16 2 __half2float → fp32 acc
IndexHNSWSQ QT_bf16 native bf16 2 __bfloat162float → fp32 acc
Other SQ (QT_8bit, QT_4bit, …) decoded to fp32 4 fp32 accumulation

Vectors stay in their native layout on the device (up-converted to fp32 only inside
the distance computation), preserving the VRAM advantage at 500M+ scale.

Search kernel

  • Parallel beam search: one CUDA block per query; warp-cooperative distance computation for coalesced dataset loads.
  • Metrics: METRIC_L2 and METRIC_INNER_PRODUCT. Cosine = the standard faiss idiom (L2-normalize → IP), no separate metric/inverse-norm state.
  • Filtered search with CPU-HNSW-parity semantics: filtered rows are excluded from results but still traversed as waypoints; two-tier beam (valid + invalid frontier) with alpha-gated admission; brute-force fallback at high filter ratios.
  • Distance-sign convention matches faiss (min-heap internally, flipped on copy-out); per-query top-k with valid-id / sentinel handling. The standard search() path converts neighbor ids → labels on-device (no D2H→host→H2D round-trip).

Tests (faiss/gpu/test/TestGpuIndexHNSW.cpp, + test_gpu_index_hnsw.py)

CPU-parity recall gates vs brute-force IndexFlat ground truth:

Test Metric Storage Recall bar
Flat_L2 L2 fp32 0.90
Flat_IP IP fp32 0.90
Flat_Cosine IP (normalized) fp32 0.90
SQ_Int8_L2 L2 int8 (direct_signed) 0.70
SQ_Fp16_L2 L2 fp16 0.88
SQ_Fp16_Cosine IP (normalized) fp16 0.85
SQ_Bf16_Cosine IP (normalized) bf16 0.80
CopyToThrows search-only contract
RejectsUnsupportedMetric metric validation

The Python test drives the SWIG search() path end-to-end
(index_factory("HNSW32,{Flat,SQ8,SQfp16,SQbf16}")index_cpu_to_gpu
GpuIndexHNSW.search() with SearchParametersGpuHNSW), asserting GPU↔CPU recall
parity and label/sentinel correctness for L2 and IP(cosine).

Design notes

  1. Search-only — matches the build-on-CPU / search-on-GPU use case and avoids
    duplicating the graph in host RAM; copyTo throws with a clear message.
  2. Cosine = normalize + IP — consistent with the rest of faiss/gpu.
  3. Native precision storage — mirrors the existing int8 precedent; preserves
    the VRAM advantage rather than decoding SQ codes to fp32 at upload.
  4. IndexHNSWCagra excluded — CAGRA keeps its own cuVS GPU path.

Hardware / build

  • NVIDIA GPU compute capability 7.0+ (Volta+), CUDA 12.x+.
  • New files under faiss/gpu/ + faiss/gpu/impl/; wired into faiss/gpu/CMakeLists.txt
    and faiss/gpu/test/CMakeLists.txt.

Checklist

  • Signed the Meta CLA.
  • Added tests (TestGpuIndexHNSW.cpp, test_gpu_index_hnsw.py).
  • Updated docs (README "GPU HNSW" section) + CHANGELOG.md.
  • clang-format clean (matches the repo's CI clang-format version).
  • Branch rebased on current main (net diff is exactly this feature).

premal and others added 7 commits July 22, 2026 04:38
…bridged)

GPU HNSW as a first-class GPU index built on vanilla faiss::IndexHNSW (Flat/SQ
storage + faiss::HNSW graph), produced by the standard cloner like
GpuIndexFlat/GpuIndexIVF* (CPU IndexHNSW --cpu_to_gpu--> GpuIndexHNSW,
search-only; copyTo throws).

Index & cloner: copyFrom maps faiss::HNSW (CSR neighbors, entry_point, levels,
cum_nneighbor_per_level) to a flat device graph + uploads storage; cosine =
normalize + METRIC_INNER_PRODUCT. Cloner routes faiss::IndexHNSW (excluding
IndexHNSWCagra) to GpuIndexHNSW; SWIG downcast + AutoTune efSearch sweep.

Search kernel: unified layer-0 kernel with native int8 DP4A (QT_8bit_direct_signed)
and warp-cooperative coalesced loads; native FP16/BF16 device storage; parallel
bitonic-sort + merge-path merge; CPU-parity filtered search (deletes/TTL/partition
bitset) on-device with per-GPU device binding; nq-chunked layer-0 search bounds
the visited-bitmap VRAM with OOB guards.

int8 accuracy: INT8 L2/IP use QT_8bit_direct_signed + DP4A; knowhere re-encodes
int8-cosine as fp16 (SQ_Fp16_Cosine is the representative gate).

Tests (TestGpuIndexHNSW.cpp): Flat L2/IP/cosine, SQ int8 L2, SQ fp16 L2, SQ
fp16/bf16 cosine, cloner dynamic type, valid IDs, distance sign/order, recall,
unsupported copyTo/metric.

Signed-off-by: premal <premal@6sense.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Devin Review (faiss #11):
- Select the native int8 DP4A layer-0 path on a per-search i8_queries_staged
  flag (set in GpuHnswSearchScratch::ensure from use_i8_queries) instead of
  sc.d_queries_i8 != nullptr. Scratch slots are pooled and d_queries_i8 stays
  allocated after an int8 search, so a later fp32-query search on the same slot
  (e.g. searchHost/searchImpl_ on an int8 index) could take the DP4A path and
  score against stale int8 query data. Gating on the staged flag makes the
  fp32 fallback correct for reused slots.
- Wrap comment/source lines to the 80-char CONTRIBUTING.md limit (no behavior
  change).

Signed-off-by: premal <premal@6sense.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…arch() tests

searchImpl_ (the faiss-standard GpuIndex::search() device-pointer path) no
longer round-trips labels through the host: a new convert_labels_kernel maps
the uint64 neighbor ids to idx_t (UINT64_MAX -> -1) directly on the slot
stream, writing straight into the caller's device output. Drops the host
staging alloc, the D2H copy, the CPU convert loop, and the H2D copy back;
a single end-of-search stream sync remains to order the private slot stream
before GpuIndex::search copies outputs back. Production searchHost/searchHostInt8
are unaffected (they already avoid the round-trip).

Adds faiss/gpu/test/test_gpu_index_hnsw.py exercising the SWIG-exposed
search() path: index_factory(HNSW32,{Flat,SQ8,SQfp16}) -> index_cpu_to_gpu
-> GpuIndexHNSW.search() with SearchParametersGpuHNSW, asserting GPU<->CPU
recall parity and label/sentinel correctness for L2 and IP (cosine).

Signed-off-by: premal <premal@6sense.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
index_factory(HNSW32,SQ8) builds an IndexHNSWSQ whose scalar quantizer must be
trained before add(); the test now calls train() first (a no-op for Flat/fp16/
bf16). Fixes the test_sq8_l2 is_trained assertion.

Signed-off-by: premal <premal@6sense.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…n README

Signed-off-by: premal <premal@6sense.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Sync gpu-hnsw with upstream facebookresearch/faiss main (34 commits).
Resolved faiss/gpu/GpuCloner.cpp: keep the IndexHNSW.h include and adopt
upstream's CAGRA guard (USE_NVIDIA_CUVS && !FAISS_CUVS_NO_CAGRA).

Signed-off-by: premal <premal@6sense.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Apply clang-format-21 (the version faiss CI enforces) to the GPU HNSW
sources/tests and add an Unreleased CHANGELOG entry referencing facebookresearch#5458.
Formatting-only; no functional change.

Signed-off-by: premal <premal@6sense.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
devin-ai-integration Bot and others added 3 commits July 24, 2026 07:25
An empty IndexHNSW (ntotal==0) reaching the GPU cloner is now left to the
"not implemented on GPU" fall-through instead of being routed to
GpuIndexHNSW::copyFrom (which throws "index must not be empty"). That throw
did not match the "not implemented on GPU" substring GpuIndexIVF::copyFrom
keys on, so cloning an untrained IVF_HNSW (HNSW coarse quantizer, ntotal==0)
to the GPU with allowCpuCoarseQuantizer=true regressed from a CPU-coarse-quantizer
fallback into a hard error (TestGpuAutoTune::test_params). GpuIndexHNSW is
search-only, so an empty graph has nothing to upload; populated standalone
HNSW indexes still clone to the GPU unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: premal <premal@6sense.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Signed-off-by: premal <premal@6sense.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@cjnolet

cjnolet commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Hi Premal,

I don’t think we need multiple “GPU accelerated HNSW” options in faiss, especially since most of the new GPU support is going through the cuVS backend. Let’s continue to work together on the CAGRA challenges you’ve surface and figure out how to get what you need out of it.

@pankajsingh88

Copy link
Copy Markdown
Contributor

Hi Premal! Can we close this task since as cjnolet mentioned, we're investing in cuVS backend with Nvidia to add support for GPUs

@premal

premal commented Jul 27, 2026

Copy link
Copy Markdown
Author

@pankajsingh88 @cjnolet Is it not possible to provide users different options based on their needs?

I'll give my perspective in relation to using milvus as a vector DB. They have multiple indexing options available that use both CPU and GPU. Some indexing algos need CPUs for index creation while CAGRA needs GPUs. With the code in this PR, you can continue building the HNSW index using CPUs, but if you need higher throughput and lower latency, you can switch to the GPUs for searches.

Here's something else to consider - There is a cuvs issue open since more than 2 months here - NVIDIA/cuvs#2102. It tells you that cuvs is not returning the right results as the scale of the index increases. In this PR, I have provided proof that the recall does not suffer when switching to the GPUs and the throughput is much higher.

I think this deserves a further discussion. Would it be possible to hop on a call?

@mnorris11

Copy link
Copy Markdown
Contributor

Per conversation in prior meeting, @cjnolet will discuss further with @premal.

I am also surprised by the low CPU HNSW QPS. Did you build 10 separate HNSW graphs across 10 nodes, and then at search time you search all and combine results? Is the latency due to cross machine coordination or the actual search? What are your search time parameters and required recall etc?

@premal

premal commented Jul 27, 2026

Copy link
Copy Markdown
Author

@mnorris11 The use-case I'm testing for has 530M records and growing. Those don't fit in a single node even with 384-dims. I've been using milvus for this workload. Milvus combines records within segments, so I end up with around 100 segments. The searches need to be done in each segment and then combined to return the top-k.

LMK if I'm missing something, but I've tried with different CPU instance types and end up with a similar QPS.
Milvus uses the same map-reduce mechanism with the GPU instances but the searches are much faster.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants