[MOD-18863] Reuse hnsw internal id in updateVector - write-in-place mode - #1059
nonirosenfeldredis wants to merge 8 commits into
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #1059 +/- ##
==========================================
- Coverage 97.55% 97.49% -0.06%
==========================================
Files 142 142
Lines 9095 9199 +104
==========================================
+ Hits 8873 8969 +96
- Misses 222 230 +8 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…rnal id addVector on an existing single-value label used to delete-then-append: the old id went through the normal removal's swap-to-last compaction, and the new vector landed on a fresh id - shuffling some unrelated element's id in the process for no reason, since the label's vectors are about to be replaced 1:1 anyway. storeNewElement now takes an optional elementId to overwrite an existing slot instead of appending. removeVectorInPlace splits into repairConnectionsAndDetach (repair neighbors, replace the entry point if needed, free the element's own graph data - no compaction) and the compaction tail, plus a new overwriteVectorInPlace that composes detach + reuse-store + link under one continuous lock. HNSWIndex_Single::addVector uses it instead of delete-then-append. For the tiered in-place path, split deleteVector's flat-side cleanup into deleteFromFrontAndInsertJobs so addVector can drop the buffered copy without deleting the backend copy first - which would destroy the very id we want reused. Also invalidate any repair job still pending against the old id before overwriting it: a stale job from an earlier, unrelated async deletion that named this id as a neighbor would otherwise run against the new element sitting in that slot later - found via a real crash under stress testing (asserted on a mismatched toplevel), fixed by dropping the job the same way deleteLabelFromHNSWInplace already does before a real removal. Scope is deliberately single-value and in-place only; async and multi-value are unaffected. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
updateVectors on a multi-value label, in write-in-place mode, used to delete every one of the label's ids and re-append n fresh ones - the same unnecessary id churn the single-value overwrite fix addressed, just with several ids instead of one. updateMultiValueInPlace pairs the label's existing ids with the new blobs one for one and overwrites each pair with overwriteVectorInPlace, reusing each id in place. If the label is shrinking, the extra old ids are removed for real - same invalidateRepairJobs/fixJobsAfterSwap/readySwapJobs bookkeeping deleteLabelFromHNSWInplace already uses, re-fetching the current id set on every removal since removing one can relocate another of the same label's own ids via the swap-to-last compaction. If it's growing, the extra new blobs are freshly appended. updateVectors now routes multi-value + VecSim_WriteInPlace here instead of the old delete-everything path; async is unaffected. Needed two small supporting changes: storeNewElement now skips setVectorId when reusing a slot, since a multi-value label lookup appends rather than assigns - calling it again on a reused id would have recorded it twice. Added removeIdFromLabel, to drop one specific id from a label's set without wiping the whole label the way removeLabel does, needed for the shrink case where the label keeps its other ids. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…d a swap-job accumulation bug reuseOnUpdate (default true) lets addVector/updateVectors, in write-in-place mode, fall back to the pre-reuse delete-then-append behavior on demand - a kill switch, not a tuning knob, following the setEf/setEpsilon precedent of an unlocked per-index runtime setter (safe under the same "main thread only" assumption writeMode already relies on). Fixed a high-severity bug Cursor Bugbot caught in review: updateMultiValueInPlace wrote raw caller blobs straight into the backend via overwriteVectorInPlace and addVector, skipping frontendIndex->preprocessForStorage - the step every other direct-to-backend write already does, since the backend assumes pre-normalized input for cosine. Reused and freshly-appended blobs alike now get preprocessed first. Added a regression test that fails without the fix (confirmed by reverting it locally and observing the stored vector come back unnormalized). Also fixes a bug in executeSwapJob, extracted from executeReadySwapJobs's inline loop during the removeAndSwapMarkDeletedElement/swapDeletedElement split: idsToRemove was taken by value instead of by reference, so fixJobsAfterSwap's accumulation into it never reached the caller, and idToSwapJob was never cleaned up after a GC round - reproduced by a full suite run (idToSwapJob stuck at 1000 entries, followed by a crash). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
0afcea2 to
2573734
Compare
… multi-value shrink-to-zero branch The extracted single-value in-place overwrite helper declared a mismatched template (`template <class, class>` against parameter types instead of concrete types), which doesn't match the out-of-line `auto`-parameter definition. Both now use the concrete types the class template already determines: HNSWIndex<DataType, DistType> * and MemoryUtils::unique_blob. Also add a regression test for updateVectors shrinking a multi-value label to zero vectors, the one shrink outcome that exercises removeIdFromLabel's "drop the label once its last id is gone" branch - flagged as uncovered by codecov and not previously exercised by any test. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…accumulation updateVectors' write-in-place reuse branch called updateMultiValueInPlace unconditionally, writing straight into the still-untrained HNSW backend and skipping the running-sum/insert-job bookkeeping that addVector/deleteVector maintain during SQ8 accumulation. Training writes must stay in FLAT in both write modes, so this path is now skipped while sqAccumulationState is set, falling back to the generic delete-then-add path which is accumulation-safe. Adds a regression test that fails without the fix (backend gets touched during accumulation) and passes with it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit cd9bb91. Configure here.
| if (state.currMaxLevel < state.elementMaxLevel) { | ||
| this->unlockIndexDataGuard(); | ||
| } | ||
| } |
There was a problem hiding this comment.
Failed overwrite leaves dangling graph slot
Medium Severity
overwriteVectorInPlace detaches and destroys the slot before storeNewElement allocates replacement graph data. That constructor throws on allocator failure, so the label still points at a slot whose others and incoming-edge lists are already freed. A caught low-memory error then leaves the index corrupted: later search, repair, or index teardown can use or double-free that memory. The previous delete-then-append path removed the label and compacted the slot first, so a failed re-insert did not leave a live dangling occupant.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit cd9bb91. Configure here.
dor-forer
left a comment
There was a problem hiding this comment.
Very good job!
Just a few minor comments
| idType id = hnsw_index->getElementIds(label).back(); | ||
| hnsw_index->removeIdFromLabel(label, id); |
There was a problem hiding this comment.
We can replace these two calls with a popLastIdFromLabel(label) method on the HNSW backend interface. The multi-value implementation could use back() and pop_back() directly on its private ID list, removing the label if it becomes empty. This avoids copying the entire list in getElementIds() and then searching it again in removeIdFromLabel(), while preserving encapsulation and reading the current mapping after each compaction.
Something like:
idType popLastIdFromLabel(labelType label) override {
auto it = labelLookup.find(label);
assert(it != labelLookup.end());
auto &ids = it->second;
assert(!ids.empty());
const idType id = ids.back();
ids.pop_back();
if (ids.empty()) {
labelLookup.erase(it);
}
return id;
}
If it is too much of a hustle you can leave it
There was a problem hiding this comment.
its defintly better!
| blob + i * this->frontendIndex->getInputBlobSize()); | ||
| hnsw_index->addVector(storage_blob.get(), label); | ||
| } | ||
| ++this->directHNSWInsertions; |
There was a problem hiding this comment.
Should this be directHNSWInsertions += n? The counter tracks vectors written directly to HNSW, while this helper processes n vectors. Incrementing once undercounts multi-vector updates and counts an insertion when n == 0 only deletes the label. Please cover both cases in the statistics assertions.
| GenerateVector<TEST_DATA_T>(grown + j * dim, dim, 3000 + j); | ||
| } | ||
| ASSERT_EQ(tiered_index->updateVectors(label, grown, per_label), VecSimUpdate_OK); | ||
| ASSERT_EQ(hnsw_index->getElementIds(label).size(), per_label); |
There was a problem hiding this comment.
Could we also assert ID reuse during shrink and grow? After shrinking, check that the surviving ID belongs to the original set, then save it and verify that it remains after growing. Currently these sections would also pass with delete-and-append behavior.
| idType id = old_ids[i]; | ||
| // Same reasoning as the single-value overwrite in `addVector`: a repair job filed by some | ||
| // earlier, unrelated async deletion may still be pending against this id. | ||
| this->invalidateRepairJobs(id); |
There was a problem hiding this comment.
Could we add a regression test that queues a repair job for an existing multi-value ID, overwrites that ID in write-in-place mode before the job runs, then drains the queue and runs GC? Please verify that the old job is invalidated, swap bookkeeping completes, and the replacement vectors and graph remain valid.
|
@dor-forer very good comments! |
removeIdFromLabel had no remaining callers after popLastIdFromLabel took over id removal during in-place updates; remove it from the interface and both label-lookup implementations. Also fix updateVectorsMultiInPlaceInvalidatesPendingRepairJob, which asserted a repair job would be filed while the index was already in in-place write mode (where deletes repair synchronously and never leave one); it now deletes asynchronously before switching to in-place for the update, with a third anchor label so the swap that follows doesn't disturb the id being tested. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…Place + repairEntryPoint Callers now explicitly sequence link-repair, entry-point replacement, and freeing the element's own graph data, instead of one function doing all three - overwriteVectorInPlace and removeVectorInPlace each call the three steps directly. Also updates the doc comments that referenced the old function name and fixes a column-limit/pointer-style formatting slip in the new definition. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…them updateMultiValueInPlace only needed the count of a label's current ids to compute how many to shrink by, but getElementIds(label).size() built and threw away the whole id vector (a real copy for multi-value) to get it. getLabelSize returns the count directly - O(1) for single-value, a size() read with no copy for multi-value. Also fixes a stray line-wrap/pointer-style formatting slip and clarifies a comment in repairConnectionsInPlace from the previous commit. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>


Summary
updateVector/updateVectorsin write-in-place mode used to delete the label's existing vector(s) and re-append fresh ones - unnecessarily shuffling ids via the swap-to-last compaction a real removal uses, even though the label's vectors are being replaced 1:1 (or close to it) anyway.storeNewElementgained an optionalelementIdto overwrite an existing slot in place instead of appending. The detach half ofremoveVectorInPlacesplits intorepairConnectionsInPlace(repair every affected neighbor's connections) andrepairEntryPoint(replace the entry point if needed), called together withdisposeElementData(free the element's own graph data, no compaction) by bothremoveVectorInPlaceand a newoverwriteVectorInPlace, which composes that detach + reuse-store + link under one continuousindexDataGuardlock.HNSWIndex_Single::addVectornow overwrites an existing label in place viaoverwriteVectorInPlaceinstead of delete-then-append. The tiered in-place path required splittingdeleteVector's flat-side cleanup intodeleteFromFlatAndInsertJobs(so the backend copy is left alone for the reuse to find) and invalidating any repair job still pending against the reused id (found via a real crash under stress testing - a stale job from an earlier, unrelated async deletion would otherwise run against the new element in that slot).updateMultiValueInPlacepairs the label's existing ids with the new blobs one for one and reuses each pair in place. Extra old ids (label shrinking, including all the way to zero - which is functionally a delete) are removed for real, with the same bookkeepingdeleteLabelFromHNSWInplacealready uses; extra new blobs (label growing) are freshly appended. Each reused/appended blob is preprocessed the same way any other direct-to-backend write is, so quantized/cosine backends store correct values.reuseOnUpdatetoggle:setReuseOnUpdate/isReuseOnUpdatelet a caller fall back to the original delete-then-append behavior (default is reuse-on, matching the description above).addVector/deleteVector.getLabelSize(label)to the label-lookup interface soupdateMultiValueInPlacecan read a label's current id count in O(1) instead of building and discarding the whole id vector viagetElementIds(label).size().removeIdFromLabelfrom the label-lookup interface oncepopLastIdFromLabelcovered every remaining id-removal call site.Test plan
overwriteReusesInternalId(single-value): id stability, other labels' ids untouched, graph integrity, search correctness after overwrite.updateVectorsMultiInPlaceReusesIds(multi-value): same-count (full reuse), shrinking (partial reuse + real removal), growing (partial reuse + fresh append), id/size/integrity/search checks throughout.updateVectorsMultiInPlaceShrinkToZeroRemovesLabel: shrinking a multi-value label to zero vectors removes every id and the label itself.updateVectorsMultiInPlaceInvalidatesPendingRepairJob: a repair job left pending against a reused id from an earlier, unrelated async deletion is invalidated before the id is overwritten, instead of later running against the new vector.updateVectorsMultiInPlaceNormalizesCosine: reused and freshly-appended blobs are both preprocessed correctly for a cosine backend.reuseOnUpdateToggleDisablesReuse: toggling reuse off restores the old delete-then-append id behavior, for both single- and multi-value.updateVectorsMultiInPlaceDuringAccumulationStaysInFlat: an in-place multi-value update during SQ8 accumulation stays entirely in FLAT and keeps the running-sum bookkeeping correct.hnsw_blob_sanity_test, which explicitly encoded the old swap-to-last relocation behavior as its contract.test_hnswsuite (374 tests) passes, stress-run 8x back-to-back with no flakes/crashes under real concurrent execution.🤖 Generated with Claude Code
Note
High Risk
Changes core HNSW mutation, graph repair, and tiered async repair invalidation; incorrect reuse could corrupt the graph or apply stale repair jobs to wrong slots.
Overview
Write-in-place updates no longer delete a label’s vector(s) and re-append (which shuffled unrelated internal ids via swap-to-last). The HNSW layer now detaches an element from the graph without compaction (
repairConnectionsAndDetach+disposeElementData), reuses the same slot via optionalelementIdinstoreNewElement, and re-links throughoverwriteVectorInPlace.Single-value
addVectoroverwrites in place. Tiered write-in-place routes throughupdateVectorInPlace/updateMultiValueInPlace(pair ids with new blobs, shrink/grow, invalidate stale async repair jobs before reuse).setReuseOnUpdate(false)restores delete-then-append; SQ8 accumulation still uses the old path.Async write mode is unchanged.
Reviewed by Cursor Bugbot for commit d5415d7. Bugbot is set up for automated code reviews on this repo. Configure here.