From 33da49cc59e336f7afe91bd66f0e0ea16be921d1 Mon Sep 17 00:00:00 2001 From: Enrico Olivelli Date: Sat, 16 May 2026 09:35:20 +0200 Subject: [PATCH] Prune unexpandable candidates from the search frontier The candidates queue in GraphSearcher is an unbounded GrowableLongHeap. The neighbor callback in searchOneLayer pushed every scored neighbor onto it unconditionally (~maxDegree pushes per expanded node, one pop), so the heap grew to many thousands of entries within a single search. Each push then sift-ups over a long[] far larger than CPU cache, making AbstractLongHeap.upHeap cache-miss bound. Profiling a graph-build workload showed upHeap at ~43% of CPU, more than the vector comparisons themselves. A candidate scoring below the worst kept result can never be expanded: stopSearch() terminates the loop before it reaches the top of the queue, and approximateResults.topScore() only rises. Such candidates are pure heap bloat (the HNSW frontier-pruning step, previously omitted). Divert these candidates to the existing evictedResults buffer (a NodesUnsorted with O(1) append and no sift-up) instead of the hot candidates heap. evictedResults is already drained back into candidates at the start of searchLayer0 and setEntryPointsFromPreviousLayer, so resume() and layer descent stay bit-exact with no new fields. Applied to both the sync searchOneLayer and the async searchOneLayerAsync paths. Closes #10 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../jbellis/jvector/graph/GraphSearcher.java | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/GraphSearcher.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/GraphSearcher.java index 0aee891df..6a0ca53bb 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/GraphSearcher.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/GraphSearcher.java @@ -482,7 +482,16 @@ public void searchOneLayer(SearchScoreProvider scoreProvider, var scoreFunction = scoreProvider.scoreFunction(); ImmutableGraphIndex.NeighborProcessor neighborProcessor = (node2, score) -> { scoreTracker.track(score); - candidates.push(node2, score); + // A candidate worse than the worst kept result can never be expanded: + // stopSearch() terminates the loop before it reaches the top of the queue, + // and approximateResults.topScore() only rises. Divert it to evictedResults + // so it stays out of the hot candidates heap but is still reconsidered if + // the search is later resume()d. + if (approximateResults.size() >= rerankK && score < approximateResults.topScore()) { + evictedResults.add(node2, score); + } else { + candidates.push(node2, score); + } visitedCount++; }; view.processNeighbors(level, topCandidateNode, scoreFunction, visited::add, neighborProcessor); @@ -562,7 +571,14 @@ private void searchOneLayerAsync(SearchScoreProvider scoreProvider, if (visited.add(friend)) { float friendSim = scoreFunction.similarityToNeighbor(currentNode, i); scoreTracker.track(friendSim); - candidates.push(friend, friendSim); + // Same frontier pruning as the sync path: a neighbor worse than the + // worst kept result can never be expanded, so keep it out of the hot + // candidates heap while preserving it in evictedResults for resume(). + if (approximateResults.size() >= rerankK && friendSim < approximateResults.topScore()) { + evictedResults.add(friend, friendSim); + } else { + candidates.push(friend, friendSim); + } visitedCount++; } }