Skip to content

feat: GPU HNSW search with parallel beam-search kernel — native FP32/FP16/BF16/INT8#1

Closed
devin-ai-integration[bot] wants to merge 22 commits into
mainfrom
gpu-hnsw-faiss
Closed

feat: GPU HNSW search with parallel beam-search kernel — native FP32/FP16/BF16/INT8#1
devin-ai-integration[bot] wants to merge 22 commits into
mainfrom
gpu-hnsw-faiss

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Jun 26, 2026

Copy link
Copy Markdown

Summary

Adds GpuIndexHNSW — a GPU implementation of HNSW search using a custom parallel beam-search kernel. Accepts a CPU-built IndexHNSW/IndexHNSWSQ via copyFrom() / copyFromWithMetric(), uploads vectors + graph to VRAM, frees the CPU copy, and runs search entirely on GPU. Recall is tuned via ef.

The device dataset is stored in four native precisions, each kept in its on-disk layout (no up-conversion at upload) and up-converted to fp32 per element inside the kernel for distance accumulation:

type source storage device bytes/elem kernel load_elem
FP32 HNSW-Flat 4 float
FP16 HNSW_SQ QT_fp16 2 __half2float
BF16 HNSW_SQ QT_bf16 2 __bfloat162float
INT8 HNSW_SQ QT_8bit_direct_signed 1 static_cast<float>

Dispatch is driven by an explicit enum instead of a single bool flag:

enum class GpuHnswDatasetType { FP32, INT8, FP16, BF16 };
// GpuHnswSearch.cuh
switch (idx.dataset_type) {
  case FP16: launch_kernels(static_cast<const half*>(idx.d_dataset), ...); break;
  case BF16: launch_kernels(static_cast<const __nv_bfloat16*>(idx.d_dataset), ...); break;
  case INT8: launch_kernels(static_cast<const int8_t*>(idx.d_dataset), ...); break;
  default:   launch_kernels(static_cast<const float*>(idx.d_dataset), ...); break;
}

FP16/BF16 mirror the pre-existing INT8 native path: from_faiss_hnsw_sq() copies the raw 2-byte SQ codes verbatim via upload_halfwidth_dataset() (bit-compatible with CUDA half/__nv_bfloat16), and for COSINE computes d_inv_norms from the fp32-decoded values (stored codes are not normalized). The templated distance helpers (thread_l2_distance/thread_ip_distance) were already DataT-generic; only the new load_elem overloads and the dispatch were added. The graph-walk algorithm is unchanged.

Key design decisions:

  1. Parallel beam-search kernel (GpuHnswSearchKernel.cuh): Layer-0 search uses a sorted result buffer (ef entries in smem) + a staging buffer (sw × max_degree0) merged by thread 0 via insertion sort. Stagnation is detected via num_parents == 0 (early exit). An earlier "OCQ" overflow queue was removed as dead code — see the decision record docs/design-docs/design_docs/20260711-gpu-hnsw-ocq-removal.md in 6si/milvus#6. Use a larger ef for higher recall.

  2. 1-thread-per-distance: Each of 128 threads computes one full distance independently — 128 concurrent distances vs 32 with 4-thread cooperative; the concurrency outweighs coalescing at these vector sizes.

  3. Native low-precision storage: FP16/BF16 (2 bytes) and INT8 (1 byte) stay in their native layout on device — 2×/4× less VRAM than fp32 (critical at tens of millions of rows/node) — with per-element up-conversion to fp32 in the kernel.

  4. Per-segment streams + scratch pool: each GpuHnswDeviceIndex owns a cudaStreamNonBlocking; searches acquire a GpuHnswSearchScratch slot (+ stream) from GpuHnswScratchPool for concurrent execution across Milvus segments.

Host API:

gpu_index->searchHost(nq, h_queries, k, h_distances, h_labels, params);
// params.ef, params.search_width, params.max_iterations, params.thread_block_size

Verification: compile-verified via the GPU build; GPU-runtime recall/latency/VRAM validated on-cluster — see Performance below and the unit/recall/sanitizer gates (fp32/fp16/bf16/int8 × L2/IP/COSINE).

Performance (CPU-HNSW vs GPU)

Benchmarked on the same dataset and harness (collection mpd_v2, 569.9M rows, INT8 COSINE; run_eval_jobs.py, --sample-size 2048 --continuous-iterations 30). Best cell = batch 512, 8 parallel × 4 workers (32 concurrency):

Cluster Nodes Throughput p50 latency R@1
GPU HNSW (g7e.2xlarge) 8 28,087 vec/s 518 ms 0.897
CPU HNSW (r8g.2xlarge Graviton) 32 4,365 vec/s 3,683 ms 0.889
CPU HNSW (r8g.2xlarge Graviton) 10 1,509 vec/s 9,178 ms 0.891
  • GPU on 8 nodes ≈ 6.4× the throughput of CPU on 32 nodes and ≈ 18.6× the 10-node CPU cluster, at ~7× lower p50 latency.
  • Peak GPU throughput = 30,913 vec/s (batch 256, 8 parallel × 1 worker; p50 62 ms, R@1 0.897).
  • Recall parity throughout: R@1 ≈ 0.889–0.900 on both CPU and GPU.
  • CPU HNSW is CPU-bound — each node pins at its 8-vCPU limit; throughput scales ~linearly with node count while extra concurrency only inflates latency (CPU 8×4 b1024 → p50 18s, p99 30s). GPU sweet spot is batch 256–512; batch 1024 only helps at the highest concurrency.

Throughput figures are from the gpu-hnsw-v89 image (this beam-search kernel; fp32/int8 COSINE). v89 is 10–24% faster than v83 at all matched batch-512 cells (4×1 +11%, 4×4 +11%, 8×1 +24%, 8×4 +14%), so the filtered-search path does not regress unfiltered throughput. Native fp16/bf16 throughput is a follow-up; correctness across fp32/fp16/bf16/int8 × L2/IP/COSINE (and filtered deletes/TTL/partition at CPU parity) is covered by the unit/recall/sanitizer gates and the sealed-segment cluster e2e.

Related PRs: 6si/knowhere#2 (Knowhere wrapper + registrations), 6si/milvus#6 (Milvus integration).

Link to Devin session: https://6sense.devinenterprise.com/sessions/d39aba56b8a3467cbbf231ab631a06ed

Link to Devin session: https://6sense.devinenterprise.com/sessions/55dab0bd33c346df99b5818b30059342
Requested by: @premal

@premal premal self-assigned this Jun 26, 2026
@devin-ai-integration

Copy link
Copy Markdown
Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

GPU HNSW index for faiss (GpuIndexHNSW): build + OCQ beam-search kernel,
int8/fp16/fp32 support. Clean feature-only commit on current main; dead
cooperative-distance code (select_threads_per_dist/coop_l2_distance/
coop_ip_distance, zero callers) excluded.

Signed-off-by: Devin AI <devin-ai-integration[bot]@users.noreply.github.com>
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

premal and others added 2 commits July 11, 2026 19:38
- default overflow_factor to 2 in internal GpuHnswSearchParams so the
  default search path keeps the overflow candidate queue enabled
- propagate config_.device into the scratch pool instead of hardcoding
  device 0 (fixes cross-device streams on non-default devices)
- make setSearchParams sticky (no consume-once race under concurrency)
- align copyFrom header signature with the knowhere::IndexHNSW impl and
  declare copyFromWithMetric
- query the 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>
The overflow candidate queue was never wired in: overflow_insert() had no
call site and d_overflow_count was only ever zeroed and read, so the
num_parents==0 fallback always no-oped. It only allocated nq*overflow_factor*ef
scratch VRAM + a per-search memset for a queue that was never populated.

Remove overflow_factor from the public/internal search params, the d_overflow_*
scratch buffers, overflow_insert(), and the dead fallback. The kernel is a plain
parallel beam search; raise ef for more recall.

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 added a commit to 6si/milvus that referenced this pull request Jul 11, 2026
…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>
devin-ai-integration Bot added a commit to 6si/knowhere that referenced this pull request Jul 11, 2026
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>
@devin-ai-integration devin-ai-integration Bot changed the title feat: GPU HNSW search with OCQ beam search kernel, INT8/FP32 support feat: GPU HNSW search with parallel beam-search kernel, INT8/FP32 support Jul 11, 2026
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

premal and others added 2 commits July 11, 2026 21:57
…em clamp)

- searchImpl_: make the scratch-pool stream wait on the resources' default
  stream (event) before reading queries copied there by GpuIndex::search,
  fixing a host-query race (finding facebookresearch#15).
- GpuHnswSearchScratch/GpuHnswDeviceIndex: record the owning device and
  cudaSetDevice() before cudaFree in their destructors (multi-GPU, facebookresearch#19/facebookresearch#20).
- GpuHnswSearch: throw a clear error when search_width is too large to fit
  even ef=1 in shared memory instead of clamping ef negative (facebookresearch#18).

Findings facebookresearch#17 (level off-by-one) and facebookresearch#21/facebookresearch#22 (IP/cosine sign) are false
positives: this fork uses the standard FAISS levels[] convention and Knowhere
un-negates IP/COSINE and normalizes cosine queries around searchHost.

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>
Add native fp16/bf16 support to GpuIndexHNSW, mirroring the existing int8
native path. fp16/bf16 HNSW_SQ codes (QT_fp16/QT_bf16) are stored raw as
IEEE half/bfloat16 (2 bytes/element), bit-compatible with CUDA half /
__nv_bfloat16, and uploaded verbatim to the device (no up-conversion to
fp32 at upload, preserving the memory win). The search kernel dispatches on
a new GpuHnswDatasetType enum (FP32/INT8/FP16/BF16) and the templated
distance helpers select the matching load_elem overload, up-converting each
element to fp32 for accumulation. Cosine inverse-L2-norms are computed from
the fp32-decoded values (mirroring int8). Graph-walk algorithm unchanged.

Compile-verified only; GPU-runtime validation pending capacity.

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>
@devin-ai-integration devin-ai-integration Bot changed the title feat: GPU HNSW search with parallel beam-search kernel, INT8/FP32 support feat: GPU HNSW search with parallel beam-search kernel — native FP32/FP16/BF16/INT8 Jul 11, 2026
…strings

Address external review findings on the GPU HNSW code:
- H1: add cudaStreamSynchronize safety net in GpuHnswScratchPool::release so a
  slot's stream is drained before the slot is reused; guards against a caller
  that skips its own sync (latent use-after-free via ensure() free+realloc).
- L2: GpuIndexHNSW docstring updated to list fp16/bf16 (native 2-byte device
  storage) alongside float32/int8, and copyFrom SQ type list.
- C1: document that searchHost is the preferred entry point and searchImpl_
  round-trips labels D2H->H2D; a GPU-side label-conversion kernel is a
  follow-up. Knowhere uses searchHost.
- H5: document the HnswT levels[] 1-based-count convention required by
  extract_hnsw_layers.
- L1: document num_layers as informational (search uses num_upper_layers_built).

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

Copy link
Copy Markdown
Author

Pushed 1638541c7 addressing an external code review of the GPU HNSW code:

  • H1GpuHnswScratchPool::release() now cudaStreamSynchronizes the slot's stream before returning it to the pool. Callers (searchHost/searchImpl_) already sync, so this is normally a no-op; it's a safety net against a future caller that skips its own sync (whose in-flight buffers could otherwise be freed+realloced by the next acquirer's ensure()).
  • L2GpuIndexHNSW docstring updated to list fp16/bf16 (native 2-byte device storage) alongside float32/int8.
  • C1 — documented that searchHost is the preferred entry point and searchImpl_ round-trips labels D2H->H2D (a GPU-side label-conversion kernel is a follow-up; Knowhere uses searchHost).
  • H5 — documented the HnswT levels[] 1-based-count convention required by extract_hnsw_layers.
  • L1 — documented num_layers as informational (search uses num_upper_layers_built).

Other review items were verified as already-correct or intentional (const_cast, cudaSetDevice-in-dtor, ef-not-validated matching the CPU checker). The C1 GPU-kernel label-conversion and the cosine-recall investigation (H4) are tracked as follow-ups; GPU unit tests are running now on an L40S.

The CPU cosine index records inverse L2 norms from the ORIGINAL input vectors
(HasInverseL2Norms::get_inverse_l2_norms()). Recomputing norms from decoded/
quantized codes (or normalizing decoded fp32 vectors in place) diverges from the
CPU index's scores and HNSW traversal for lossy SQ8/int8/fp16/bf16 cosine. Thread
the stored inverse norms from from_faiss_hnsw_sq into the int8/halfwidth/fp32
upload paths and apply them in the kernel; only flat-fp32 cosine (decoded ==
original) keeps normalizing in place.

Byte-identical with the knowhere vendored copy.

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>
@devin-ai-integration

Copy link
Copy Markdown
Author

Codex P1 #1 — upload CPU inverse L2 norms for quantized cosine (2f5150915)

The knowhere CPU cosine index records inverse L2 norms from the original input vectors (HasInverseL2Norms::get_inverse_l2_norms()). This GPU build previously recomputed norms from decoded/quantized codes, and the fp32 fallback normalized decoded vectors in place — both diverge from the CPU index's scores and HNSW traversal for lossy SQ8/int8/fp16/bf16 cosine.

Fix: thread the stored inverse-norm pointer from from_faiss_hnsw_sq into the int8 / halfwidth (fp16/bf16) / fp32 upload paths and apply it in the kernel. Only flat-fp32 cosine (decoded == original) keeps normalizing in place. throw if a cosine SQ index is missing its inverse norms.

Byte-identical with the knowhere vendored copy (6si/knowhere#2 08ced662); diff is clean. Runtime validation via the new knowhere [gpu_hnsw_p1] cosine-parity test on L40S (v67, queued).

premal and others added 4 commits July 12, 2026 02:54
Add GpuHnswUploadFaultInjection (host-safe, in GpuHnswTypes.h): a countdown that
makes the Nth GPU_HNSW_BUILD_CUDA_CHECK in the device-upload path report a
simulated failure. Never armed in production (countdown 0 -> one relaxed atomic
load per wrapped call, no effect); lets Knowhere unit tests exercise the upload
error/cleanup/retry path without a real OOM.

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>
node_ids is read-only after construction, so copy directly from it in the
cudaMemcpy instead of duplicating into h_node_ids. 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>
Phase 1 optimization: all 128 threads now participate in both the
staging sort and result merge instead of thread 0 doing all the work
while the other 127 idle.

Changes:
- GpuHnswSearchKernel.cuh: add bitonic_sort_staging (128-element,
  128-thread, in-place) and parallel_merge_into_result (binary-search
  per output slot, O(log N) divergence) as replacements for the serial
  sorted-insert loop; add merged_ids/merged_dists/merged_expanded
  scratch arrays to shared memory; update calc_layer0_smem_size
- GpuHnswSearch.cuh: update smem_overhead formula (24 bytes/ef slot
  instead of 12, adds 3 merge scratch arrays); add power-of-2 check
  on search_width × max_degree0 required by bitonic sort

Three correctness bugs fixed during development:
1. Bitonic sort race: each thread now writes only to slot i (not
   partner), eliminating inter-warp write-write races (778K instances
   detected by compute-sanitizer --racecheck)
2. Negative ri index: binary search exit can yield si = min(pos,sc)+1
   giving ri = pos-si = -1; added ri_valid guard and ri_safe = max(0,ri)
   clamp on all array reads
3. Speculative -O2 loads: nvcc speculatively evaluates array loads even
   when the branch predicate is false; fixed by clamping ri_mid =
   max(0, pos-mid) in the binary search body and adding an explicit
   sentinel override when pos < mid

Measured results (milvus-gpu-hnsw cluster, mpd_v2 570M INT8, 8 querynodes,
v76 serial baseline vs v78 this commit):

Single client:
  batch=256: 4,638 vec/s vs 4,022 (+15%), p50 52ms vs 63ms (-17%)
  batch=128: 3,783 vec/s vs 3,685 (+3%),  p50 33ms vs 32ms (flat)

8 parallel clients (production-representative):
  batch=64:  11,181 vs  9,919 vec/s (+13%)
  batch=128: 14,720 vs  9,986 vec/s (+47%)
  batch=256: 17,432 vs  7,355 vec/s (+137%)

ncu barrier stall: 69.89% → 39.67% (-30pp)
ncu long-scoreboard stall: 4.77% → 21.01% (+16pp, now new bottleneck)
Remaining stall is global memory latency from distance compute (__ldg
on INT8 dataset), not the merge — next optimization target.

Recall unchanged: R@1=0.899, R@10=0.937 across all configurations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… merge

Forward-port Knowhere's native int8 DP4A search into faiss's parallel
bitonic-merge kernel so the two GPU HNSW trees stop diverging. The two
layer-0 kernels (serial int8 + parallel fp32) collapse into one kernel
templated on <DataT, QueryT, USE_DP4A>:
- generic path: QueryT=float, USE_DP4A=false (fp32/fp16/bf16/int8 via
  load_elem, L2 or negated-IP);
- native int8 path: DataT=QueryT=int8_t, USE_DP4A=true, thread_ip_distance_dp4a.
Both share the same shared-memory layout and parallel_merge_into_result.

Adds searchHostInt8() (uploads signed int8 queries verbatim, no shift;
fp32 fallback when dim%4!=0) plus the d_queries_i8 scratch field/ensure()
arg, and INT8 DP4A dispatch (int8 + inner-product/cosine + dim%4==0).

Review fixes folded in:
- pad layer-0 staging to next_pow2(search_width*max_degree0) and grow the
  block to match, instead of hard-failing when 2*M is not a power of two;
  throw a clear error only if the padded capacity exceeds the 1024 block
  limit;
- warn (stderr) when ef is clamped to fit shared memory instead of
  silently truncating;
- document the negated IP/cosine distance convention and the (corrected,
  no +128 shift) int8 query encoding in GpuIndexHNSW.h.

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]

This comment was marked as resolved.

@premal

premal commented Jul 17, 2026

Copy link
Copy Markdown

Code Review — Delta (DP4A + Padded Staging)

⚠️ Still Open from Previous Review

[I8] No validation that k ≤ ef — If a caller sets k > ef, the kernel silently returns fewer than k results (padded with sentinels UINT64_MAX / FLT_MAX). Consider adding FAISS_THROW_IF_NOT_MSG(k <= ef, ...) in searchHost / searchImpl_, or auto-adjusting ef = max(ef, k).

New Changes — No New Critical Issues

DP4A path is correct. __dp4a usage with aligned packed int32, dim%4==0 guard at dispatch, no int32 overflow risk. Bit-exact for QT_8bit_direct_signed. The QueryT/USE_DP4A template threading via if constexpr is clean.

Minor suggestions on new code:

  • [D1] Add a comment on the DP4A dispatch explaining that use_ip is the gate because DP4A only computes dot products (not L2). Currently the guard sc.d_queries_i8 != nullptr && idx.use_ip && (dim % 4 == 0) is correct but the rationale isn′t documented.

  • [D2] searchHostInt8 does a full cudaStreamSynchronize after uploading fp32_q (for host buffer lifetime safety). This serializes the stream before the kernel launches — consider pinned memory + deferred sync as a follow-up.

Previously flagged, still present (low priority):

  • #include <atomic> in GpuIndexHNSW.h is unused
  • GpuHnswScratchSlot destructor missing cudaSetDevice before cudaStreamDestroy (multi-GPU safety)
  • cudaFuncSetAttribute called on every search (correct but wasteful; should cache per template instantiation)

Positives: Padded staging is consistently used across kernel, smem calc, and merge call sites. The d_layer0_queries vs d_queries distinction correctly keeps upper-layer greedy descent on fp32 while layer-0 uses int8. The ef clamp warning and block-size error messages are good UX improvements.

Full review: static analysis only (no CUDA/GPU available for compile/test verification).

premal and others added 3 commits July 17, 2026 03:53
…esult

The diagonal binary search compared staging[mid] against result[pos-mid]
instead of result[pos-mid-1], mis-partitioning every merge (~4% recall on
mpd_v2) and, with padded staging, promoting a UINT32_MAX sentinel into the
result list that was then used as a graph/dataset index -> CUDA illegal
memory access (the v81 crash at GpuIndexHNSW.cu:332). Fix uses ri_mid =
pos-mid-1 with a -inf boundary when ri_mid<0. Validated on CPU against
std::merge over 200k random (rc,sc,ef) cases: 0 failures (was 187330/200000).

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…raph indexing

Bounds-check every node id before it indexes d_layer0_graph or d_dataset in
layer0_beam_search_kernel:
- entry point (ep): skip seeding + neighbor scan if ep >= N (ep_valid gate)
- parent id: skip expansion if parent >= N (mirrors the existing nbr guard)
- top-k output: emit UINT64_MAX sentinel if result_ids[i] >= N

Prevents the v82 fp16 P1 OOB (illegal global read in
layer0_beam_search_kernel<__half,float,false>, addr ~= bad_id*max_degree0*4)
from a stray/garbage node id reaching graph/dataset indexing. On the healthy
path (valid ep, correct merge) none of these fire, so recall is 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>
…pare-exchange)

Split the bitonic compare-exchange into two barrier-separated phases. Each
thread owns writes to slot i but READS slot partner=i^j; for partners in a
different warp (j >= warpSize) that read raced with partner's write to its own
slot in the same phase (racecheck: ~1.6M hazards in <__half,float,false>, ~664K
in <float,float,false>). Now: read pair + decide swap -> __syncthreads ->
conditional write of own slot -> __syncthreads. This is the actual root cause of
the v82 fp16 P1 OOB (the stale read produced a garbage node id); fp32 hit the
same race but only OOB'd at large N. Sort result is unchanged (canonical
two-phase bitonic); loop bounds stay uniform so every thread reaches every
barrier.

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>
@premal

premal commented Jul 17, 2026

Copy link
Copy Markdown

Code Review — Delta 2 (Bitonic Sort Race Fix + Defensive Guards)

Both bug fixes verified correct via brute-force simulation

1. Bitonic sort race fix — ✅ Correct
The two-phase barrier-separated compare-exchange (read-all → __syncthreads → write-all → __syncthreads) eliminates the cross-warp shared-memory hazard. Verified via:

  • Structural proof: do_swap is always symmetric for both threads in a pair
  • 50,000 full bitonic sort trials: 0 mismatches vs reference sort
  • 100,000 full merge trials: 0 mismatches, 0 sentinel promotions

2. Merge-path ri_mid off-by-one fix — ✅ Critical bug fixed
The old formula ri_mid = pos - mid was wrong. Brute-force over 1.65M test cases:

  • Old formula: 50.02% mis-partitioned — explains the ~4% recall loss and sentinel-promotion crashes
  • New formula (pos - mid - 1): 0% wrong across all edge cases

This was a real correctness bug, not just a defensive guard.

3. Defensive guards — ✅ Correct, don't mask live bugs
With the merge fix in place, sentinels never enter result_ids[0..rc) on the healthy path (0 sentinel promotions in 100K trials). The guards are belt-and-suspenders against OOB faults — good practice.

Still Open (carried forward)

  • I8: No k ≤ ef validation
  • I7: cudaFuncSetAttribute per-search caching
  • M1 (unused #include <atomic>), M4 (ScratchSlot destructor missing cudaSetDevice)

Verified via Python simulation (no CUDA/GPU available for compile/test).

…amp ef>=k

- Negate IP/COSINE score on kernel copy-out so the public API returns true
  similarity (larger == closer), matching faiss METRIC_INNER_PRODUCT; make the
  padding sentinels metric-aware (-FLT_MAX for IP/COSINE, FLT_MAX for L2).
- Validate ef>0 and search_width>0 before computing the default iteration
  count (prevents divide-by-zero).
- Auto-clamp ef=max(ef,k) so k results are always tracked; retain the
  shared-memory-fit guard that throws when k itself cannot fit.
- Cache cudaFuncSetAttribute per high-water dynamic shared-memory size.
- Set the owning device before destroying a scratch stream (multi-GPU).
- Remove unused <atomic> from GpuIndexHNSW.h; correct the public distance
  documentation; wrap the long merge-path line.

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

premal commented Jul 18, 2026

Copy link
Copy Markdown

Code Review — Delta 3 (k<=ef clamp, smem caching, distance convention)

Previously-Flagged Items — All Addressed

  • [I8 FIXED] k <= ef: Auto-clamp ef = max(ef, k) + clear error if k can't fit in shared memory ✅
  • [I7 FIXED] cudaFuncSetAttribute caching: Now uses static std::atomic<size_t> configured_smem per template instantiation ✅ (but see new issue below)
  • [M4 FIXED] ScratchSlot destructor: cudaSetDevice(scratch.device) added before cudaStreamDestroy
  • [M1 FIXED] <atomic> include now actually used ✅

New Issue — Important (should-fix)

cudaFuncSetAttribute caching TOCTOU race: The static std::atomic with load-check-store pattern has a time-of-check-to-time-of-use race. Two concurrent searches with different ef values on the same template instantiation can interleave so the smaller-ef search's cudaFuncSetAttribute runs after the larger one, downgrading the global kernel attribute while configured_smem records the larger value. A future search with intermediate smem skips the set (thinks it's configured) but the actual attribute is too small → kernel launch failure.

Verified via Python simulation. Fix: CAS loop or mutex to ensure monotonic increase — only set the attribute if the new smem_size is strictly greater than the current configured_smem, and loop until CAS succeeds.

Distance Convention Change — Correct

The copy-out now negates IP/cosine back to true similarity (-result_dists[i]), L2 passed through. Sentinel padding is metric-aware (-FLT_MAX for IP/cosine empty, FLT_MAX for L2 empty). Upper-layer kernel correctly unchanged (uses internal negated convention, only produces entry points). Requires knowhere to stop negating on its side — confirmed done.

Degenerate Param Validation — Correct

if (ef <= 0 || sw <= 0) throws with a clear error message before any kernel launch. Good defensive check.

No new correctness bugs. Static analysis only (no CUDA/GPU for compile/test).

Comment thread faiss/gpu/impl/GpuHnswSearchKernel.cuh
Comment thread faiss/gpu/impl/GpuHnswBuild.cuh
Comment thread faiss/gpu/impl/GpuHnswSearch.cuh
…pose metric

- Replace the racy std::atomic load-check-store around cudaFuncSetAttribute
  with a mutex-guarded, monotonic high-water update. Two concurrent searches
  with different smem sizes could interleave so a smaller-ef call ran after a
  larger one and downgraded the kernel's global max-dynamic-smem attribute
  while a recorded high-water mark said otherwise, making a later intermediate
  launch fail. Under the lock the attribute only ever grows and is always >=
  the current launch's requirement.
- Validate hnsw.entry_point in [0, n_rows) in extract_hnsw_layers before it is
  used to index d_dataset on device, turning a malformed/sentinel entry_point
  into a host-side throw instead of an OOB device read.
- Add GpuIndexHNSW::isCosine()/useInnerProduct() accessors, set at
  copyFrom()/copyFromWithMetric() time, so callers can post-process results
  from the index-side metric instead of a per-search config that may omit
  metric_type (and default to L2).

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>
@premal

premal commented Jul 18, 2026

Copy link
Copy Markdown

Code Review — Delta 4 (TOCTOU mutex fix + entry_point validation + isCosine API)

All Previously-Flagged Should-Fix Items Now Resolved

  • [TOCTOU race FIXED] cudaFuncSetAttribute caching now uses static std::mutex + std::lock_guard instead of the atomic load-check-store pattern. The mutex makes check-set-store atomic and monotonic — the kernel attribute only ever grows. Race fully eliminated. ✅

  • [Entry-point validation NEW] Validates hnsw.entry_point < 0 || >= n_rows before GPU upload. Catches malformed CPU indexes with sentinel entry points (e.g. -1 → UINT32_MAX) that would cause OOB device reads. Error message includes the actual value and n_rows. ✅

  • [isCosine()/useInnerProduct() API NEW] Clean public accessors that return the metric interpretation captured at copyFrom()/copyFromWithMetric() time. This is the faiss-side enabler for knowhere's I1 fix — knowhere can now derive the metric from the GPU index instead of search config. ✅

No New Issues

No critical or important issues. Minor: reset() doesn't clear is_cosine_/use_ip_ (stale values, but safe since searches guard on is_trained).

All previously-flagged should-fix items from our review series are now resolved. No blockers for merge.

reset() left is_cosine_/use_ip_ at their prior copyFrom() values. Safe
today (searches guard on is_trained), but a subsequent copyFrom() of an
L2 index would inherit stale cosine/IP interpretation. Clear them so
reset() fully returns the index to an untrained, metric-neutral state.

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

premal commented Jul 18, 2026

Copy link
Copy Markdown

Code Review — Delta 5 (reset() clears metric fields)

✅ M1 from Delta 4 — FIXED

reset() now clears is_cosine_ and use_ip_ alongside deviceIndex_, ntotal, and is_trained. This addresses our delta 4 minor finding about stale metric fields surviving a reset. Clean fix.

No new issues. All previously-flagged items remain resolved.

premal and others added 3 commits July 18, 2026 21:00
…set)

Add GPU-side bitset filtering to layer-0 HNSW search so GPU_HNSW serves segments with deletes/TTL/partition visibility, matching CPU HNSW rather than rejecting a non-empty bitset.

Kernel: is_bitset_filtered() device test (byte id/8, bit id%8, LSB-first, matches Knowhere BitsetView); filtered rows stay graph waypoints but are never emitted. Two-tier beam (valid ef + invalid frontier ef_inv) with serialized post-sort alpha gate (accumulated_alpha per query in meta[4], init 1.0f; kAlpha=filter_ratio*0.7) and cross-beam parent selection (globally-closest unexpanded across both beams). HAS_FILTER template param; unfiltered path codegen-identical.

Brute force (GpuHnswBruteForce.cuh): GPU-resident up-front BF (>=0.93 filtered or k>=0.5*live) and per-query fallback via device needs_bf[] worklist (no host round trip).

Host: per-slot d_bitset/d_needs_bf scratch (grow-only ensure_filter), bitset upload before launch, needs_bf_count zeroed via cudaMemsetAsync, smem re-budget (36 B/ef filtered), disable_fallback_brute_force threading. Threaded through all 5 dtypes.
Signed-off-by: premal <premal@6sense.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…GPU)

Signed-off-by: premal <premal@6sense.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
feat(gpu_hnsw): CPU-parity filtered search (deletes/TTL/partition bitset)
@premal

premal commented Jul 19, 2026

Copy link
Copy Markdown

Delta 6 Review — faiss PR #1 (filtered search merged in)

The filtered search implementation from PR #7 has been folded into PR #1. Reviewed the delta against our previous PR #7 review.

Previously-Flagged Items

  • I1 (cudaSetDevice in ensure_filter): ✅ FIXEDensure_filter now starts with cudaSetDevice(device). Additionally, searchHost/searchHostInt8 set the device before calling, making it doubly-ensured.
  • I2 (alpha gate order divergence): Not addressed — by design. Acknowledged in design doc §5.3, deferred to recall gate validation at 30-70% filter ratios.
  • M1-M5 (minor): Not addressed — all non-blocking. Type punning (M1), unused meta[5] (M2), uninit invalid frontier (M3), no bounds check in is_bitset_filtered (M4), BF block size 128 (M5).
  • C1 (TOCTOU race): ✅ Preserved — mutex fix correctly carried into per-<HAS_FILTER> instantiation.

New Issues (both minor)

  • N1 (Minor): The unfiltered initial-seed path has one extra __syncthreads() (after the if constexpr block) that parallel_merge_into_result already provides. Functionally harmless (~few cycles), but means the "codegen-identical" comment is slightly inaccurate. The main loop does this correctly (sync is inside the if constexpr branch only) — inconsistency is only in the initial seed section.
  • N2 (Trivial): searchHostInt8's dim%4 fallback to searchHost skips device setup, but searchHost does its own — safe.

Verdict

No blockers. Ready for compilation and the full recall/sanitizer gate. The I1 fix is the key item and it's resolved. The integration is clean — HAS_FILTER template, smem budget (36 B/ef filtered, 24 B/ef unfiltered), bitset upload, BF fallback launch orchestration, and destructor cleanup are all correct.

Full review at $M/reports/review-faiss-pr1-delta6.md

premal and others added 2 commits July 19, 2026 22:58
The layer-0 expansion computed each candidate's full-dim distance on a single
thread, so a warp's 32 lanes read 32 different vectors (rows dim apart) -> ncu
measured 12.1 sectors/request (~8% coalescing), 49% of warp stalls on global
load latency, and DRAM at only 37.6% of peak.

Assign one warp per candidate edge: lane 0 resolves the neighbor id and claims
the visited-bitmap first-visit, broadcasts go/no-go + id, then all 32 lanes
stride the vector and warp-reduce the partial sums (DP4A int8, generic IP, and
L2). Loads coalesce toward 1 sector/request on the dominant traffic source.
The predicate is warp-uniform so the warp never diverges across the __shfl
reductions. Host rounds block_size up to a whole number of warps.

Distance values, staging contents, and result ordering are unchanged (only the
parallelization of the identical computation changes); correctness/recall to be
confirmed by the existing recall + sanitizer suite.

Signed-off-by: premal <premal@6sense.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
perf(gpu_hnsw): warp-cooperative layer-0 distance (coalesced dataset loads)
@devin-ai-integration

Copy link
Copy Markdown
Author

Superseded: we re-cut clean gpu-hnsw branches from v2's base. Current PR is #11. Closing; branch retained.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant