Repository navigation
[MOD-19284] Reuse ids on async multi-value updateVectors - #1075
Conversation
An async updateVectors on a multi-value label deleted every old id and appended n fresh ones, so the index grew until GC ran. The label's old ids are now reserved, one per new vector, for the new vectors' insert jobs, which are held back until their id's repairs are done and then store into it. Surplus old ids (the label shrinks) go to normal compaction, and surplus new vectors (it grows) append. deleteLabelFromHNSW takes a span of reuse jobs and nulls the entries whose submission it delegated to the repair flow. Falls back to delete-then-add when the flat buffer can't hold all n vectors. Plain addVector on a multi-value label is unchanged. Gated on reuseIdOnUpdate. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #1075 +/- ##
==========================================
- Coverage 97.55% 97.53% -0.03%
==========================================
Files 142 142
Lines 9336 9405 +69
==========================================
+ Hits 9108 9173 +65
- Misses 228 232 +4 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Route every executeReadySwapJobs call through defrag() and record, per index, the number of defrag runs, the swap jobs they executed and the wall time they took (including the wait for the main index guard). The values are reported in the tiered HNSW debug info as TIERED_HNSW_DEFRAG_RUNS, _SWAP_JOBS and _TIME_NS. executeReadySwapJobs now returns how many jobs it ran. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…jobs Drop the swap-jobs counter from the tiered HNSW info and log the number of swap jobs each defrag executes at debug level instead. Count backend GC runs and their time in the tiered SVS index as well, reported as TIERED_SVS_DEFRAG_RUNS and TIERED_SVS_DEFRAG_TIME_NS. The time includes the wait for the main index guard, as for HNSW. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
defrag() now logs a debug-level summary after each run, so the tiered index log in this test has one more entry. Its elapsed time varies, so only the fixed part of the message is compared. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
| const char *blob = static_cast<const char *>(new_blobs); | ||
| vecsim_stl::vector<HNSWInsertJob *> insert_jobs(this->allocator); | ||
| insert_jobs.reserve(n); | ||
| this->flatIndexGuard.lock(); |
There was a problem hiding this comment.
I think a scoped std::lock_guard be better here?
| const void *new_blobs, size_t n) { | ||
| // Drop the label's buffered copies first, which also makes room for the new ones. | ||
| this->deleteFromFlatAndInsertJobs(label); | ||
| if (this->frontendIndex->indexSize() + n > this->flatBufferLimit) { |
There was a problem hiding this comment.
Are the capacity check and staging loop should under a scoped std::lock_guard on flatIndexGuard?
| auto [it, inserted] = this->labelToInsertJobs.try_emplace(label, 1, job, this->allocator); | ||
| if (!inserted) { | ||
| it->second.push_back(job); | ||
| } |
There was a problem hiding this comment.
Why do need try_emplace in every loop run (it's the same label).
We can, reserve it once before the loop and just push it every itertion:
auto it = this->labelToInsertJobs.try_emplace(label, this->allocator).first;
auto &label_jobs = it->second;
label_jobs.reserve(n);
And then in the loop
label_jobs.push_back(job);
| } | ||
| template <typename DataType, typename DistType> | ||
| auto TieredHNSWIndex<DataType, DistType>::shouldDefrag() { | ||
| return readySwapJobs >= this->pendingSwapJobsThreshold; |
There was a problem hiding this comment.
I think we need to guard readySwapJobs here also, maybe we can make it atomic?
…ySwapJobs - updateMultiValueAsync: scoped lock_guard on flatIndexGuard that also covers the flat buffer capacity check; look up the label's insert-job vector once before the staging loop. - readySwapJobs is atomic: shouldDefrag() reads it on the writer thread while workers update it. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Jira: MOD-19284
Follow-up to #1068 (async id reuse for single-value overwrites), extending it to multi-value labels.
Summary
updateVectorson a multi-value label deleted every old id and appendednfresh ones, leaving the old ids marked deleted until swap jobs compacted them - so repeated updates grew the index until GC ran.n == m(old count): every old id is reused, nothing to compact.n < m(shrink):nids are reused, the remainingm - ngo through normal compaction.n > m(grow):mids are reused, the remainingn - mvectors are appended as fresh ids.updateMultiValueAsync: drops the label's buffered copies, buffers thennew blobs in the flat buffer with their insert jobs (not yet submitted), then marks the label's HNSW vectors deleted before any new vector can be ingested (otherwise they'd be marked deleted too), pairing them with the jobs. Falls back to the previous delete-then-add path if the flat buffer can't hold allnblobs, ifn == 0, or during SQ accumulation.deleteLabelFromHNSWnow takes astd::span<HNSWInsertJob *>of reuse jobs instead of a single job, and returns just the deleted count. Entries whose submission was delegated to the repair flow (their id has repairs pending) are set tonullptr, and the caller submits the rest. The single-valueaddVectorcall site is adapted to the same interface.updateVectorsreuses ids. A plainaddVectoron an existing multi-value label still just appends (it isn't a replacement).reuseIdOnUpdateflag (setReuseOnUpdate(false)restores the old behavior).Locking
Unchanged from #1068: all the held-job, deferred-submission and reuse-store logic is reused as is. The only new concurrency consideration is the order in
updateMultiValueAsync: allninsert jobs are created and registered inlabelToInsertJobsunderflatIndexGuard, but none is submitted untildeleteLabelFromHNSWhas marked the old ids deleted, so a worker can't ingest a new vector into the label before the old ones are marked.The capacity check and the staging loop run under one scoped
flatIndexGuardlock, and the label'slabelToInsertJobsentry is looked up once before the loop.readySwapJobsis now atomic, sinceshouldDefrag()reads it on the writer thread while worker threads update it.Defrag cost counters
Observability for how much GC costs, separate from the id-reuse change above.
defrag()now also accounts for its own cost, so GC time can be read off an index: number of runs and wall time (including the wait for the main index guard). All callers (runGC,addVector,deleteVector,updateMultiValueAsync) already go through it. Reported in the tiered HNSW debug info asTIERED_HNSW_DEFRAG_RUNSandTIERED_HNSW_DEFRAG_TIME_NS. The number of swap jobs each run executed is logged at debug level instead of being counted;executeReadySwapJobsnow returns it. Callers that bypassdefrag()(benchmarks) are not counted.runGC(); a GC on an empty index returns early and is not counted), reported asTIERED_SVS_DEFRAG_RUNSandTIERED_SVS_DEFRAG_TIME_NS, with the time also logged at debug level.Test plan
asyncUpdateVectorsMultiReusesIds(same count, shrink, and grow: checks which ids are reserved, that reused ids are a subset of the old ones, index size / marked-deleted / ready-swap-job counts before and after GC, and that other labels are untouched),asyncUpdateVectorsMultiReleasesIdsWhenUpdatedAgain,asyncUpdateVectorsMultiWithReuseDisabledAppends.invalidRepairJobOnSwapalso checks the HNSW run count (at least 2) and time (> 0) and that the debug info matches;runGCAPI(1 run) andrunGCInPlaceMode(threshold / 64 + 1runs) check the SVS counters. The tiered info-field tests were updated for the new fields (expected field count HNSW 16 -> 18, SVS 18 -> 20, field order, per-field comparison). Tiered HNSW (434) and tiered SVS plus info-iterator tests (151) pass.test_hnswtiered tests: 434 pass; swept overVECSIM_SEED1-12 with no failures. After the review fixes (scoped lock, atomicreadySwapJobs): the*Tiered*filter intest_hnsw(298 tests, ASan debug build) passes forVECSIM_SEED1-6.clang-format18.1.8 clean on all changed files.test_int8/test_uint8/test_bf16/test_fp16/test_hnsw_sq8, the broader seed sweep, and a concurrency stress run - relying on CI for these for now.🤖 Generated with Claude Code
Note
Medium Risk
Changes async deletion, repair, and insert job orchestration for multi-value updates; incorrect id reservation or submission could affect graph integrity until GC, though heavily tested.
Overview
Async multi-value
updateVectorsnow reuses internal HNSW ids instead of delete-then-append, matching the existing single-value overwrite behavior whenreuseIdOnUpdateis on.deleteLabelFromHNSWtakes astd::span<HNSWInsertJob *>so one deleted id can be reserved per pending insert job; jobs whose submission is deferred to the repair path are nulled in the span. NewupdateMultiValueAsyncstages allnblobs and jobs under the flat lock, marks old vectors deleted before submit, then reuses ids pairwise (with fallback to delete-then-add if the flat buffer cannot holdnblobs).Defrag/GC observability: tiered HNSW centralizes swap compaction in
defrag()(timing + run count);readySwapJobsis atomic withshouldDefrag()for threshold checks. Tiered HNSW and tiered SVS exposeTIERED_*_DEFRAG_RUNSandTIERED_*_DEFRAG_TIME_NSin debug info. Tests cover multi-value reuse, re-update invalidation, reuse-disabled behavior, and counter fields.Reviewed by Cursor Bugbot for commit b0c52fc. Bugbot is set up for automated code reviews on this repo. Configure here.