HNSW: reuse pooled id sets in the delete and repair paths - #1009
Draft
dor-forer wants to merge 1 commit into
Draft
HNSW: reuse pooled id sets in the delete and repair paths#1009dor-forer wants to merge 1 commit into
dor-forer wants to merge 1 commit into
Conversation
repairNodeConnections built two vecsim_stl::vector<bool> sized by the index
capacity on every call, and repairConnectionsForDeletion built one more per
call, while removeVectorInPlace re-zeroed a capacity-sized bitmap once per
level. Each of those sets never holds more than a node's links and its
neighbors' links, so the cost was an allocation plus a zero fill over the
whole index for a handful of ids.
Add IdFlagSet, a bit-per-id set that records which bits it set so that
clear() is proportional to the set's size, and hand pairs of them out from a
pool per delete and per repair job, the way VisitedNodesHandlerPool already
does for graph scans. The pool releases its sets when the index capacity
drops to zero, so an emptied index still returns to its baseline memory.
Measured on a Xeon 8375C (16 threads, dim 32, M 16, 3000 deletes):
in-place delete 250K: 214 -> 199 us (-7%)
1M: 379 -> 288 us (-24%)
2M: 532 -> 344 us (-35%)
async delete CPU 1M: 3033 -> 2897 us (-4%)
2M: 3687 -> 3220 us (-13%)
Async wall time is unchanged to ~2% worse, since the repair work is spread
over the background threads and the zero fill was parallel with it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Describe the changes in the pull request
Three places in the HNSW delete paths build a
vecsim_stl::vector<bool>sized by the whole index, per call, to hold at most a node's links plus its neighbours' links (order tens of ids):repairNodeConnectionsmaxElements, allocated + zero-filled per background repair jobrepairConnectionsForDeletioncurElementCountper call: once per bidirectional neighbour and once per incoming edge, per levelremoveVectorInPlaceassign(curElementCount, false)once per level (buffer reused, but the zero fill is O(N) each time)At 2M vectors that is roughly 250 KB of allocate-and-zero per repair job, for a job whose real work is a few hundred distance computations.
This PR replaces them with a pooled set that keeps O(1) lookup but clears in time proportional to its own size: in-place deletes get 35% faster at 2M vectors and the gain grows with index size; the async repair path saves CPU but not wall-clock time.
One hazard worth reviewer attention:
removeVectorInPlaceends inremoveAndSwap->shrinkByBlock->resizeIndexCommon, which resizes the pool. Holding a checked-out set across that trips the pool's "nothing in use" assertion, so the scratch is scoped to end before it.Which issues this PR fixes
None. This came out of profiling the delete paths, not a planned task, so there is no MOD ticket. Happy to file one if the convention requires it.
Not MOD-9645, though it overlaps it: that task reuses internal element ids for new inserts to avoid swap jobs, which is a different mechanism (this PR changes no id allocation and removes no swap job). It will edit
repairNodeConnectionstoo, and its reported blocker is a repair-job race in this same code, so the two should be sequenced rather than merged blind. The pooled sets are id-indexed and cleared per call, so recycled ids do not break them.Main objects this PR modified
IdFlagSet(new,src/VecSim/algorithms/hnsw/id_flag_set.h) - one bit per id plus the list of ids it set, soclear()is proportional to the set's size rather than the index capacity.IdFlagSetPair/IdFlagSetPool(new) - hands out both sets a delete or repair job needs in a single pool round-trip, mirroringVisitedNodesHandlerPool. Releases its sets when index capacity drops to zero, so an emptied index returns to baseline memory.PooledIdFlagSets(new) - RAII holder, so an early return cannot leak a set out of the pool.HNSWIndex::repairNodeConnections,::repairConnectionsForDeletion(signature now takes both sets from its caller),::removeVectorInPlace- switched to the pooled sets.HNSWIndex::resizeIndexCommonand bothHNSWIndexconstructors - pool lifecycle, next to the existingvisitedNodesHandlerPoolcalls.Mark if applicable
Neither box applies.
hnsw_serializer_impl.his touched, but only to initialize and size the new pool in the deserializing constructor; the on-disk format and the encoding version are unchanged.Results
Xeon Platinum 8375C, 16 cores, gcc 13.3, RelWithDebInfo, tiered HNSW, dim 32, M 16, 3000 deletes per phase, 16 background threads. Two clones of the same commit (one patched), binaries run alternately, 3 reps each, mean reported.
In-place delete path (single-threaded:
removeVectorInPlace/repairConnectionsForDeletion), us per delete:Patched run-to-run spread also collapses (287.6 / 287.8 / 289.1 at 1M, against 377.6 / 414.0 / 424.0 baseline), which is what removing a size-dependent memset looks like.
Async repair path, us per delete:
The async path saves CPU (throughput headroom) but not latency: the zero fill it removes was already spread across the 16 background threads, and what remains is one pool mutex round-trip per job. Cutting the lock traffic (one scratch pair per job rather than per call, which is what this branch does) narrowed that but did not close it.
Tests
2606 / 2607unit tests pass on the patched tree.HNSWTieredIndexTest.swapJobBasic(x2) failed at first and now passes: that is what drove the pool releasing its sets at zero capacity, so an emptied index still returns to its baseline memory.IndexAllocatorTest.test_hnsw_reclaim_memoryfails: 849,620 actual vs 848,680 expected, i.e. exactly the 940 bytes of pooled scratch retained while the index is non-empty.test_allocator.cpp:606is a whitebox model of every allocation, so any new accounted consumer breaks it by construction. Nothing leaks: the memory is accounted throughVecSimAllocatorand released when the index empties.Open decision
Notes
Unrelated papercuts found while measuring, not fixed here:
make unit_test CTEST_ARGS='-R A|B'breaks: the Makefile passes it unquoted, so the shell splits on|.ctestdirectly needsROOT=<repo>, or 14 serialization tests fail insidegetenv("ROOT")with "basic_string: construction from null is not valid".-DUSE_SVS=OFFdoes not build on main:svs.h/svs_utils.hare included even withHAVE_SVS=0.🤖 Generated with Claude Code