feat: GPU_HNSW / GPU_HNSW_SQ index using faiss::gpu::GpuIndexHNSW — native FP32/FP16/BF16/INT8#2
feat: GPU_HNSW / GPU_HNSW_SQ index using faiss::gpu::GpuIndexHNSW — native FP32/FP16/BF16/INT8#2devin-ai-integration[bot] wants to merge 24 commits into
Conversation
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
|
Successfully scanned changes introduced in a pull request into Internal scan identifier:
See all issues found during this scan in the OX Security Application. Detailed information
3 aggregations
|
GPU_HNSW (and GPU_HNSW_SQ) index type via vendored faiss GpuIndexHNSW: IndexNode wiring, index-type registration, faiss_gpu_hnsw CUDA target, and recall/cosine/topk/serialize tests. Clean feature-only commit on current main; dead cooperative-distance code excluded. Signed-off-by: Devin AI <devin-ai-integration[bot]@users.noreply.github.com>
45139ad to
8e24da1
Compare
Mirror the 6si/faiss GPU HNSW review fixes into the vendored copy: - default overflow_factor to 2 in internal GpuHnswSearchParams - propagate config_.device into the scratch pool (no hardcoded device 0) - make setSearchParams sticky (no consume-once race) - query device shared-memory opt-in limit and opt the kernel into >48KiB dynamic shared memory instead of assuming a 48KiB cap - synchronize before the stack-local entry-point buffer is destroyed Signed-off-by: premal <premal@6sense.com> Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
- keep Count()/Dim() working after the CPU copy is freed by capturing n_rows/dim before reset and overriding Count()/Dim() (previously returned -1, making the loaded segment look empty) - make HasRawData()/GetVectorByIds() and StaticHasRawData() consistent: GPU_HNSW frees the CPU copy so it exposes no CPU-reconstructable raw data (return false / not_implemented once uploaded) - set gsp.overflow_factor=2 in the search path so the overflow candidate queue stays enabled (recall) - publish gpu_index_ through an atomic gpu_ready_ flag (release/acquire) to remove the unsynchronized double-checked read - harden the filtered-search guard to reject on bitset.data()!=nullptr instead of count() (which defaults to 0) - correct the class comment: GPU_HNSW is registered for F32 and INT8 only - add Count()/Dim() regression assertions to the GPU HNSW search test 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>
…n record The overflow candidate queue was never functional (overflow_insert had no call site; d_overflow_count only zeroed+read). Removed from the kernel in 6si/faiss#1 and 6si/knowhere#2. Update the design doc to describe a plain parallel beam search and add a decision record explaining the removal. Signed-off-by: premal <premal@6sense.com> Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Mirror of 6si/faiss#1: the overflow candidate queue was never wired in (overflow_insert had no call site; d_overflow_count only zeroed+read), so it only wasted scratch VRAM + a memset per search. Remove the overflow machinery from the vendored faiss GPU kernel and drop the explicit gsp.overflow_factor=2 in faiss_hnsw.cc. Recall is tuned via ef. Signed-off-by: premal <premal@6sense.com> Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
GPU_HNSW_SQ was exposed by Milvus (constant, param spec, tests, GPU routing) but had no Knowhere backend, so creating it hit no impl. Add a thin GpuHnswSQIndexNode subclass of GpuHnswIndexNode that only overrides Type(); the GPU search path is identical (a CPU-built HNSW/HNSW_SQ index uploaded to GpuIndexHNSW). Register it for VECTOR_FLOAT and VECTOR_INT8, add the INDEX_GPU_HNSW_SQ constant + index-table entries, and accept the GPU_HNSW_SQ binset key in Deserialize's alias path. 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>
Sync of 6si/faiss@467593eae (byte-identical vendored copy): - searchImpl_ event-based stream sync before reading host queries (zilliztech#15) - cudaSetDevice() before cudaFree in scratch/index destructors (zilliztech#19/zilliztech#20) - clear error when search_width exceeds shared-memory budget (zilliztech#18) 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>
Mirror the faiss native fp16/bf16 device-storage change into the vendored faiss copy (byte-identical to 6si/faiss) and register fp16/bf16 for both GPU_HNSW and GPU_HNSW_SQ: - index_table.h: add VECTOR_FLOAT16 / VECTOR_BFLOAT16 legal-type rows for both GPU index types. - faiss_hnsw.cc: add fp16/bf16 KNOWHERE_REGISTER_GLOBAL (feature::FP16|GPU, feature::BF16|GPU) and RegisterStaticFunc entries for both nodes; update the class comment to describe native 2-byte fp16/bf16 device storage with per-element up-conversion to fp32 in the kernel. - test_gpu_search.cc: add GPU HNSW FP16/BF16 deserialize+search UTs (CPU HNSW_SQ QT_fp16/QT_bf16 build -> GPU load -> recall vs brute force). Compile-verified only; GPU-runtime validation pending capacity. Signed-off-by: premal <premal@6sense.com> Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
… bound) - Deserialize (reload path) now clears gpu_ready_ under gpu_mutex_ before resetting gpu_index_, so a failed re-upload no longer leaves the node advertising gpu_ready_==true with a null gpu_index_ (the lock-free reader in Search() would otherwise dereference null). - Round-trip self-recall test searched train_ds (nb rows) but only checked the first nq of nb results; loop and denominator now use nb. - Document the single-visible-GPU-per-process device model of the shared StandardGpuResources (multi-GPU-per-process device selection is a follow-up). Signed-off-by: premal <premal@6sense.com> Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
The existing SQ8 test builds an unsigned QT_8bit HNSW_SQ, which routes through the decode-to-fp32 upload branch, so it does not exercise the native signed-int8 device path (upload_int8_dataset / QT_8bit_direct_signed). Add an int8 test that builds a CPU int8 HNSW and loads it as GPU_HNSW, covering all four native device representations (fp32/int8/fp16/bf16). Signed-off-by: premal <premal@6sense.com> Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…eview fixes - M1: GpuHnswIndexNode::Deserialize now returns Status::cuda_runtime_error when the eager GPU upload throws, instead of returning success with no GPU index (which only surfaced the failure on first search). Also log via LOG_KNOWHERE_ERROR_ rather than fprintf(stderr,...) so it lands in Milvus's structured logs. - Mirror the byte-identical vendored faiss changes (H1 scratch-release sync, L1/L2/C1/H5 documentation) to keep 6si/faiss and the vendored copy in lockstep. Signed-off-by: premal <premal@6sense.com> Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
Pushed
Previously-resolved review items (reset gpu_ready_ under lock on reload, self-recall test bound, fp16/bf16 index-table registration, const_cast/dtor rationale) remain in place. GPU unit tests (fp32/int8/fp16/bf16 + cosine) are running now on an L40S; I'll post measured recall numbers. |
…oad accounting Codex P1 review fixes for the GPU HNSW path: #1 Quantized-cosine inverse-norm semantics. The CPU cosine index records inverse L2 norms from the ORIGINAL input vectors (HasInverseL2Norms:: get_inverse_l2_norms()). The GPU upload previously recomputed norms from decoded/quantized codes (and normalized decoded fp32 vectors in place), which diverges from the CPU scores/traversal for lossy SQ8/int8/fp16/bf16. Now upload the CPU index's stored inverse norms and apply them in the kernel; only flat-fp32 cosine (decoded==original) keeps normalizing in place. Vendored faiss copy kept byte-identical with 6si/faiss. #2 Reload/search use-after-free race. gpu_index_ is now an atomic<shared_ptr<GpuIndexHNSW>>; Search() takes a shared-ownership snapshot for the whole search so a concurrent Deserialize() reload that resets/rebuilds the member cannot free an in-use index. Atomic readiness only guarantees visibility, not lifetime. #3 Phase-separated load-resource accounting. Resource gains maxMemoryCost (transient peak during load; defaults to 0 so other indexes keep their existing heuristic). GpuHnswIndexNode reports memoryCost=0 (CPU copy freed after upload) and maxMemoryCost covering the download buffer + deserialized CPU index + decode/graph staging. Tests (tests/ut/test_gpu_search.cc, [gpu_hnsw_p1]): native low-precision device paths (fp16/bf16/sq8 via non-mock fp32 node), SQ8/fp16/bf16 cosine CPU-vs-GPU parity asserting per-query top-1 id + per-result score on same-direction/ different-magnitude vectors, search-vs-concurrent-reload lifetime stress, >4 concurrent searches with varying nq/k/ef vs own ground truth, and load-resource estimate assertions. 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>
Codex P1 review fixes —
|
std::atomic<std::shared_ptr<T>> requires C++20 / libstdc++-12; the build toolchain is GCC 11 (libstdc++-11), where it fails the trivially-copyable static_assert. Keep the same lifetime guarantee with a plain shared_ptr guarded by the existing gpu_mutex_: Search() copies it into a local snapshot under a short lock (not held during the search), and Deserialize() already mutates it under the same lock. Reload still cannot free an in-use GPU index. 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>
v67 L40S results — P1 fixes validated at runtimeImage New
Existing GPU HNSW sections — 53/53 still pass: L2 Caveats / follow-ups:
|
L40S measures GPU HNSW cosine recall ~0.98 (comparable to L2), so the 0.65 floor was far too loose to catch a real regression. Align it with the L2/IP/TopK sections (0.85-ish) at 0.80. 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>
Codex re-review should-fix #2 — raise GPU HNSW cosine recall floor (
|
Add a test-only GpuHnswUploadFaultInjection hook (host-safe, in GpuHnswTypes.h) that arms the Nth wrapped CUDA call in the device-upload path to simulate a failure. New [gpu_hnsw_p1] section forces the eager upload to fail both immediately and mid-upload, and asserts: load returns an error, no half-published index (a re-armed search errors cleanly, no crash), VRAM returns to baseline after teardown (no leak), and a fault-free retry loads + searches correctly. Vendored faiss copy mirrors the faiss change byte-identically. 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>
Mirrors faiss byte-identically: node_ids is read-only after construction, so cudaMemcpy reads it directly. Behavior-neutral. 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>
…path Milvus loads sealed vector-index segments through VectorMemIndex::LoadFromFile -> index_.DeserializeFromFile(), not Deserialize(BinarySet). GpuHnswIndexNode only overrode Deserialize(BinarySet), so the file/mmap load path fell through to the base CPU loader and never ran the eager GPU upload: the CPU-built HNSW stayed resident in host RAM (fp32-expanded for int8/fp16/bf16 inputs, ~5.9 GiB/segment for the mpd_v2 collection) while VRAM sat near-idle, OOMKilling querynodes during the load storm before any search could trigger the lazy-upload fallback. Override DeserializeFromFile to load the CPU index via the base and then run the same eager upload + host-copy release. The shared upload/unpublish logic is factored into UploadCpuIndexToGpuLocked()/UnpublishGpuIndexLocked() so both entrypoints behave identically; GpuHnswSQIndexNode inherits the fix. Adds a [gpu_hnsw_p1] section that loads via DeserializeFromFile (enable_mmap true/false) and asserts Size()==0 before any search (i.e. indexes[0] freed), which fails pre-fix and cannot be masked by the first-search lazy upload. Signed-off-by: premal <premal@6sense.com> Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…oad fix) Picks up 6si/knowhere#2 6906c993, which runs the eager GPU upload on the DeserializeFromFile (mmap) load path that Milvus uses for sealed segments — fixing the querynode host-RAM OOM where the CPU-built HNSW was retained in host memory instead of being uploaded to VRAM and freed. Signed-off-by: premal <premal@6sense.com> Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…p eligibility Two coupled fixes for the mpd_v2 host-RAM OOM regression that appeared once the HNSW->GPU_HNSW load override was introduced. 1. StaticEstimateLoadResource previously proxied the resident deserialized index as ~file_size. For a compressed (int8) on-disk index this grossly under-counts the fp32-expanded hnswlib graph materialized in host RAM, so the loader admitted far too many concurrent uploads and OOMed the host cgroup before any GPU upload completed (VRAM stayed at 0). The estimate now sizes the transient peak as file + resident fp32 vectors (rows*dim*4) + graph neighbor lists (rows*M*2*4) + fp32 decode staging (rows*dim*4), reads M from the load config when present, and falls back to a conservative multiple of file_size when row/dim metadata is missing. 2. GPU_HNSW / GPU_HNSW_SQ were not advertised as mmap-capable, so the override HNSW->GPU_HNSW silently flipped enableMmap off and the whole CPU index landed in anonymous host RAM instead of being file-backed as plain HNSW was before the override. Re-add feature::MMAP to the registrations and list the types in legal_support_mmap_knowhere_index; the CPU index is deserialized via the mmap-capable file path and freed after the VRAM upload. Strengthen the [gpu_hnsw_p1] load-resource test to assert the estimate tracks the fp32 resident footprint (not file_size) for a compressed index and does not collapse when num_rows arrives as 0. 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>
The HNSW->GPU_HNSW load deserializes the on-disk faiss index (IndexHNSWSQCosine with QT_8bit_direct_signed for an int8 collection) into a compact host copy, uploads it to VRAM, then frees it. That transient int8 copy is ~1 byte/dim, matching how the CPU HNSW int8 node loads -- so GPU_HNSW does not need to be memory-mapped. Remove feature::MMAP from the GPU_HNSW/GPU_HNSW_SQ registrations and the mmap support table; do not rely on enable_mmap. Signed-off-by: premal <premal@6sense.com> Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
… fp32 The prior StaticEstimateLoadResource sized the transient host peak as an fp32/hnswlib index (rows*dim*4 resident + rows*dim*4 decode staging + graph), yielding ~20 GiB/seg. mpd_v2's on-disk index is the compact FAISS int8-SQ form (IndexHNSWSQCosine): it deserializes to ~file_size in host RAM and the signed int8 codes are uploaded to the device directly, so there is no fp32 reconstruct/decode staging on this path. The CPU HNSW cluster loads the same index resident at ~file_size (~1.1 GiB/seg measured). Because the estimate is charged into the querynode's committed-loading memory (indexLoadingMemoryContribution), the fp32 overestimate made the soft-OOM admission guard trip after ~3 segments (3x20 > threshold) even though real RSS was only ~16 GiB and freed back to baseline after every upload. Sizing the transient at ~2x file_size (serialized read buffer + deserialized compact index) makes admission reflect the true compact footprint. Retained cost stays 0 (data lives in VRAM after the CPU copy is freed). 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>
The test was written for the intermediate 4d3d97d commit which used an fp32-expansion-based estimate. After 7d8a448 changed the implementation to file_size*2, the test assertions were not updated and would fail on any CUDA-enabled build. Update the int8-file and no-rows test cases to assert >= 2*file_size instead of the old fp32-based bounds. Also clarify the StaticEstimateLoadResource comment to note that the no-fp32-staging claim applies to native int8/fp16/bf16 paths only, not unsigned SQ8 (QT_8bit) which decodes via sa_decode(). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Premal Shah <premal@6sense.com>
- Add searchHostInt8() to GpuIndexHNSW: applies +128 query shift to match upload_int8_dataset encoding, uploads int8 queries to d_queries_i8 - Add layer0_beam_search_kernel_int8: uses __dp4a for 4x int8 MADs/cycle - Dispatch in GpuHnswIndexNode::Search when data_format==int8, bypassing IndexNodeDataMockWrapper<int8> fp32 upcast - dim=384: max accumulator 6.2M << INT32_MAX, no overflow risk
… type Add INT8 path to gpu_hnsw_search() that dispatches to layer0_beam_search_kernel_int8 when sc.d_queries_i8 is populated. Upper-layer search still uses fp32 queries (d_queries) for the greedy entry-point descent through sparse upper layers.
V5-C1: Remove +128 query shift in searchHostInt8. upload_int8_dataset already reverses FAISS's +128 bias (codes[i]-128), so GPU stores original signed user values. Queries arrive as the same signed int8 values — no shift needed. The +128 shift was causing (q+128)*d instead of q*d. V5-C2: Gate DP4A dispatch on idx.use_ip in GpuHnswSearch.cuh. The DP4A kernel only computes dot products; L2 now falls through to the existing fp32 generic kernel. V5-H1: Replace throw on dim%4!=0 with fp32 fallback via searchHost(). V5-L1: Fix misleading comments describing the encoding.
Summary
GPU HNSW index implementation using a vendored FAISS
GpuIndexHNSWbackend. Accepts CPU-built HNSW/HNSW_SQ indexes at load time, uploads vectors and graph to GPU VRAM, frees the CPU copy, and runs a parallel beam search entirely on GPU with per-segment CUDA streams. Recall is tuned viaef.Registered index types & precisions:
GPU_HNSWandGPU_HNSW_SQ, each for fp32, fp16, bf16, and int8.GPU_HNSW_SQis a thin alias —GpuHnswSQIndexNodesubclassesGpuHnswIndexNodeand only overridesType(); the GPU search and load paths are identical.Registrations (both index types) now cover all four precisions:
Native low-precision storage: fp16/bf16/int8 stay in their on-disk layout on the device (2/2/1 bytes/element) and are up-converted to fp32 per element inside the search kernel — mirroring the pre-existing INT8 path. The vendored FAISS copy (
thirdparty/faiss/faiss/gpu/impl/GpuHnsw*.{h,cuh}) is kept byte-identical to 6si/faiss#1: aGpuHnswDatasetTypeenum drives kernel dispatch andupload_halfwidth_dataset()copies the raw 2-byte codes verbatim. TheIndexNodeDataMockWrapper<fp16|bf16>converts fp16/bf16 query inputs to the node's fp32-facing interface, while the stored dataset stays native low-precision.Load-path fix (mmap/file) — fixes the production querynode OOM: the eager GPU upload must run on both deserialization entrypoints. Milvus loads sealed segments through the mmap/file path (
VectorMemIndex::LoadFromFile → index_.DeserializeFromFile()), notDeserialize(BinarySet).GpuHnswIndexNodepreviously only overrodeDeserialize(BinarySet), soDeserializeFromFilefell through to the base CPU loader and the upload never ran: the CPU-built HNSW stayed resident in host RAM (fp32-expanded for int8/fp16/bf16 inputs, ~5.9 GiB/segment for mpd_v2) while VRAM sat idle, OOMKilling querynodes during the load storm before any search could trigger the lazy-upload fallback. Fix factors the upload/unpublish into helpers and calls them from both entrypoints:GpuHnswSQIndexNodeinherits the fix. New[gpu_hnsw_p1]test loads viaDeserializeFromFile(enable_mmap true/false) and assertsSize()==0before any search (i.e.indexes[0]freed) — fails pre-fix and cannot be masked by the first-search lazy upload.Architecture (unchanged core):
GpuHnswIndexNodewrapsfaiss::gpu::GpuIndexHNSW; accepts CPU-serialized HNSW/HNSW_SQ atDeserialize()(binset key aliased fromGPU_HNSW_SQ/HNSW_SQ/HNSW) and atDeserializeFromFile()(raw faiss index bytes / mmap). CPU copy freed after upload;Count()/Dim()captured before the free.StandardGpuResourcessingleton (GetSharedGpuResources());StaticEstimateLoadResourcereturnsmemoryCost=0;GetGpuConstructionMutex()serializes construction;gpu_ready_publishes the built index for the lock-free search fast path; per-segmentcudaStreamNonBlockingstreams.Tests: added
Test GPU HNSW FP16 DeserializationandTest GPU HNSW BF16 Deserialization(CPU HNSW_SQQT_fp16/QT_bf16build → GPU load → recall vs brute force; floors 0.9 / 0.85), plus the[gpu_hnsw_p1]regression sections (cosine-norm parity, reload lifetime, load accounting, and the new DeserializeFromFile upload/free assertion). Guarded byKNOWHERE_WITH_CUVS, so they compile/run only on GPU builds (no silent no-op pass on CPU builds).Kernel: 1-thread-per-distance, 128 concurrent candidates/block,
num_parents == 0stagnation break. The earlier non-functional "OCQ" overflow queue was removed here and in faiss#1 (decision recorddocs/design-docs/design_docs/20260711-gpu-hnsw-ocq-removal.mdin 6si/milvus#6); use a largereffor higher recall.Filtered search:
Search()rejects a non-empty bitset withinvalid_args(delete/TTL/partition). No in-node CPU fallback (copy freed after upload); GPU-side bitset filtering is a documented follow-up. Scoped to append-mostly / immutable collections for now.Verification: compile-verified via the GPU build. The DeserializeFromFile fix is validated by the
[gpu_hnsw_p1]unit test and a live single-segment + full mpd_v2 load re-run (VRAM growth + host-anon-returns-to-baseline + zero OOMKills) — in progress. The prior perf numbers (8× g7e.2xlarge, mpd_v2 569M, GPU 3.3–4.5× CPU vec/s, R@1 0.892) predate this change and covered fp32/int8; native fp16/bf16 recall/latency/VRAM validation is pending GPU capacity — not yet validated.Related PRs: 6si/faiss#1 (FAISS kernel), 6si/milvus#6 (Milvus integration).
Link to Devin session: https://6sense.devinenterprise.com/sessions/d39aba56b8a3467cbbf231ab631a06ed