Skip to content

Add update_entries method to concurrent Vamana Index - #467

Open
razdoburdin wants to merge 1 commit into
mainfrom
dev/razdoburdin/concuttent_update_entries
Open

razdoburdin wants to merge 1 commit into
mainfrom
dev/razdoburdin/concuttent_update_entries

Conversation

@razdoburdin

Copy link
Copy Markdown
Member

This PR adds a way to change the vectors already stored in the index in a concurrent regime with search/delete/insert/conslidate

update_entries(points, external_ids)
replaces the vectors with existing IDs. Both the internal ID and the external ID stay the same.

During an update, the slots are Pending. They are hidden from search. After the update they are Valid again. A delete of the same ID waits until update finisghes.

Implementation add a new shared mutex payload_mutex_ it is taken exclusive only by update_entries and only for a short payload replacement phase. All readers of the vectors take it shared. After the payload is replaced, consolidation of the incoming connections is called to repair the links to the old (deleted) vector, after that new outcoming connections are established (this logic is shared with the add_points method).

@eshenayo

Copy link
Copy Markdown

When reusing deleted slots with svs_index_dynamic_update_points + svs_index_dynamic_replace_id from #468, we found that the concurrent index's reverse-edge lists grow under churn until consolidation, which slot reuse makes rare. Filed as #469 with measurements. The code involved comes from #369 and is not changed here, so this is not a blocker for this PR; noting it because update_entries is the path that exposes it.

@eshenayo

Copy link
Copy Markdown

Benchmark and profile of update_points as used by valkey-search, shared as feedback; no change requested at this point.

Setup: 50K x 128-d FLOAT32, L2, FINE_GRAIN at 9330e49, 8 writer and 8 reader clients. Each churn round deletes, inserts and updates 2,500 keys. Inserts reuse deleted slots (update_points on the deleted id, then replace_id) and updates call update_points on the live id. The baseline is the same pin using add_points / delete_points with periodic consolidate + compact.

add/delete baseline update_points
insert / update ops/s 637 / 385 136 / 135
insert / update p99 37 / 41 ms 145 / 151 ms
search QPS during churn 1,728 501
single-writer insert p50 3.1 ms 6.2-8.0 ms

Profile (perf plus all-thread stack samples, during the insert phase):

  • 47% of writer samples are blocked in update_internal on the exclusive payload_gatekeeper_ (include/svs/concurrent/dynamic_index.h:1460) or payload_mutex_ (:1461); about 3% are doing SVS work. The server keeps about 1.9 cores busy, against about 5.7 for the baseline. Each update's exclusive copy waits for every shared holder (searches, and other updates' repair and connect phases), so updates queue behind one another.
  • Of update_entries CPU, about 65% is GraphConsolidator repairing the slot's in-edges and about 35% is the connect phase, which is the work add_points does.

Likely path forward on the valkey-search side, pending exploration to validate: keep add_points for inserts and let consolidation run concurrently, so freed slots are refilled without the per-insert repair. We are prototyping this now and will share the numbers before proposing any SVS change. Reverse-edge memory growth is tracked separately in #469.

@eshenayo

Copy link
Copy Markdown

Follow-up to the profile above: numbers for the alternative we prototyped on the valkey-search side. Same setup (50K x 128-d, L2, FINE_GRAIN at 9330e49, 8 writer and 8 reader clients, each round deletes, inserts and updates 2,500 keys, EF_RUNTIME 100).

The prototype keeps add_points / delete_points and consolidates on a background thread while writers and searches continue (FINE_GRAIN consolidate takes the shared lock), without compacting. The last two columns also use a local one-line change that passes reuse_empty = true in ConcurrentIndexVamana::add_points (bindings/c/src/index_vamana.cpp), so adds refill the slots consolidation frees.

add/delete, consolidate + compact inline update_points reuse background consolidate + reuse_empty, 10 rounds same, 40 rounds
churn writes (ops/s) 443 176 443 475
insert / update p99 38 / 37 ms 146 / 153 ms 42 / 41 ms 39 / 38 ms
worst single write 12.9 s 0.04 s 0.05 s 0.05 s
search QPS during churn 1,765 346 1,810 2,177
recall@10 after churn 0.780 0.791 0.786 0.801
memory growth over churn -12 MB +51 MB, linear +16 MB +28 MB, flat for the last 12 rounds
BGSAVE time / COW 4.2 s / 114 MB 1.5 s / 80 MB 4.8 s / 132 MB 8.3 s / 170 MB

What we take from it so far:

  • Background consolidation with reuse_empty removes the write and search regression and the consolidation stall. Without reuse_empty, freed slots are not refilled and memory grows linearly (+65 MB over 10 rounds), so compaction is still needed.
  • It does not help saves: with deleted and empty slots present, save still repairs and compacts, and with nothing compacting the index in between, it costs more over time. Only update_points reuse, which keeps every slot valid, gets the save and COW improvement.

This is still exploration on our side, so no change is requested yet. If we go this way, the C API piece would be exposing reuse_empty for FINE_GRAIN add_points. The save cost would remain open under either approach (#421).

@eshenayo

Copy link
Copy Markdown

Follow-up for review: two more prototypes for the remaining consolidation pause, measured from valkey-search. Same setup as above (50K x 128-d FLOAT32, L2, FINE_GRAIN, 8 writer and 8 reader clients, 10 churn rounds that each delete, insert and update 2,500 keys). All SVS changes are local patches on main 0b4b1c3, and all of them also pass reuse_empty = true in ConcurrentIndexVamana::add_points, so valkey-search no longer compacts.

Context: with consolidation moved to a background task in valkey-search, the remaining pauses are (a) the exclusive compact() and (b) a BGSAVE that starts during a consolidation. The fork has to wait for the running SVS call to finish, and since that wait runs on Valkey's main thread, every client pauses. To measure (b), one client issues BGSAVE every 15 s during the churn and times the command, which returns after the fork.

1. Batches through consolidate(ids): not a good fit. Exposing MutableVamanaIndex::consolidate(ids) and calling it on 512 or 2,048 ids at a time bounds the fork wait (worst BGSAVE 2.2 s / 5.6 s, against 14.6-16.4 s). But each id costs 3.45 ms (512) or 2.87 ms (2,048), against 0.95 ms in a full pass. gather_work_set and generate_updates repair each in-neighbor once per batch that touches it, and with degree 64 a few hundred deleted ids reach most of the graph. The batches fall behind the deletes and memory keeps growing (+40 MB over the run).

2. A resumable full pass: removes the pauses. consolidate_step(max_work):

  • the first step snapshots the Deleted set and the routing anchor, then calls gather_work_set once;
  • each step calls run_driver on the next max_work work ids under the shared storage lock;
  • the last step frees the slots exactly as consolidate_locked() does.

valkey-search runs one step per background task.

inline consolidate + compact background consolidate + compact resumable, 4,096-node steps resumable, 1,024-node steps
worst BGSAVE (fork wait), BGSAVE every 15 s 14.6 s 16.4 s 1.26 s 0.27 s
worst single search, same runs 15.0 s 16.4 s 1.27 s 0.28 s
worst single write, no BGSAVE probe 11-14 s 7.5-8.8 s 33-47 ms not run
churn writes (ops/s) 439 532 562 not run
longest step n/a n/a 1.0-2.8 s 0.7-0.8 s
memory during churn 63-80 MB 63-80 MB 77 -> 107 MB 77 -> 106 MB
SAVE / BGSAVE COW 3.6 s / 116 MB 3.8 s / 80 MB 13.1 s / 162 MB not run

A pass takes 12-14 steps and 13-23 s of wall time, and it keeps up with the deletes. Recall@10 after churn is 0.788 against 0.785-0.807 for the other runs.

Open points in the prototype:

  • Compaction between steps. Compaction (including the one inside save()) renumbers slots, so the saved work list then points at the wrong nodes. A real implementation should restart the pass when compaction_epoch_ changes. The prototype doesn't, and a SAVE ran during one of these benchmarks.
  • Adds between steps can link to a Deleted slot that the last step then frees. The single pass has the same race, but only for the length of one call.
  • Saves get slower. With nothing compacting in between, save() has more to repair (SAVE 13.1 s, BGSAVE COW 162 MB), so this needs a save that doesn't consolidate and compact (Dynamic Vamana save() always consolidates and compacts: full rewrite on every save, and failure when no entries are live #421).

Proposal for discussion:

  1. Refill freed slots in FINE_GRAIN add_points ([C API] Reuse slots freed by consolidate in svs_index_dynamic_add_points #438).
  2. Resumable consolidation in the concurrent index and the C API, restarting after any compaction.
  3. A save that keeps slot state instead of consolidating and compacting (Dynamic Vamana save() always consolidates and compacts: full rewrite on every save, and failure when no entries are live #421).

valkey-search keeps its current approach (background consolidate + compact) until these exist.

Prototype patch on main 0b4b1c3 (experimental, not for merge)
diff --git a/bindings/c/include/svs/c/svs_c.h b/bindings/c/include/svs/c/svs_c.h
index 0826c16..628f99e 100644
--- a/bindings/c/include/svs/c/svs_c.h
+++ b/bindings/c/include/svs/c/svs_c.h
@@ -1657,6 +1657,12 @@ SVS_API bool svs_index_reconstruct(
 SVS_API bool
 svs_index_dynamic_consolidate(svs_index_h index, svs_error_h out_err /*=NULL*/);
 
+/// EXPERIMENT: one step of a resumable consolidation (FINE_GRAIN only). Repairs up to
+/// @p max_work nodes; sets @p out_done when the pass has finished and freed its slots.
+SVS_API bool svs_index_dynamic_consolidate_step(
+    svs_index_h index, size_t max_work, bool* out_done, svs_error_h out_err /*=NULL*/
+);
+
 /// @brief Compact a dynamic index to remove deleted entries and optimize storage
 /// @param index The dynamic index handle
 /// @param batchsize The batch size for compaction (0 for default)
diff --git a/bindings/c/src/index.hpp b/bindings/c/src/index.hpp
index 5775207..8944ab1 100644
--- a/bindings/c/src/index.hpp
+++ b/bindings/c/src/index.hpp
@@ -94,6 +94,10 @@ struct DynamicIndex : public Index {
     virtual size_t delete_points(std::span<const size_t> ids) = 0;
     virtual bool has_id(size_t id) const = 0;
     virtual void consolidate() = 0;
+    // EXPERIMENT: one step of a resumable consolidation.
+    virtual bool consolidate_step(size_t /*max_work*/) {
+        throw not_implemented("consolidate_step needs SVS_SYNC_KIND_FINE_GRAIN");
+    }
     virtual void compact(size_t batchsize) = 0;
 };
 } // namespace svs::c_runtime
diff --git a/bindings/c/src/index_vamana.cpp b/bindings/c/src/index_vamana.cpp
index addc5eb..e2f09d4 100644
--- a/bindings/c/src/index_vamana.cpp
+++ b/bindings/c/src/index_vamana.cpp
@@ -305,7 +305,7 @@ size_t ConcurrentIndexVamana::add_points(
     track_id_range(ids);
     // The size may change concurrently, so it cannot measure this call; a failed add
     // throws instead.
-    index.add_points(new_points, ids);
+    index.add_points(new_points, ids, /*reuse_empty=*/true);  // EXPERIMENT
     return ids.size();
 }
 
diff --git a/bindings/c/src/index_vamana.hpp b/bindings/c/src/index_vamana.hpp
index 2a19398..999e8cf 100644
--- a/bindings/c/src/index_vamana.hpp
+++ b/bindings/c/src/index_vamana.hpp
@@ -184,6 +184,11 @@ struct ConcurrentIndexVamana : public DynamicIndexVamana {
         index.consolidate();
     }
 
+    bool consolidate_step(size_t max_work) override {
+        auto lock = read_lock();
+        return index.consolidate_step(max_work);
+    }
+
     void compact(size_t batchsize) override {
         // exclusive lock prevents potential deadlock in case of custom threadpool takes
         // internal mutex in parallel_for method
diff --git a/bindings/c/src/svs_c.cpp b/bindings/c/src/svs_c.cpp
index 6718861..a3de170 100644
--- a/bindings/c/src/svs_c.cpp
+++ b/bindings/c/src/svs_c.cpp
@@ -1588,6 +1588,25 @@ extern "C" bool svs_index_dynamic_delete_points(
     );
 }
 
+extern "C" bool svs_index_dynamic_consolidate_step(
+    svs_index_h index, size_t max_work, bool* out_done, svs_error_h out_err
+) {
+    using namespace svs::c_runtime;
+    return wrap_exceptions(
+        [&]() {
+            EXPECT_ARG_NOT_NULL(index);
+            EXPECT_ARG_NOT_NULL(out_done);
+            auto dynamic_index_ptr = std::dynamic_pointer_cast<DynamicIndex>(index->impl);
+            INVALID_ARGUMENT_IF(
+                dynamic_index_ptr == nullptr, "Index does not support dynamic updates"
+            );
+            *out_done = dynamic_index_ptr->consolidate_step(max_work);
+            return true;
+        },
+        out_err
+    );
+}
+
 extern "C" bool svs_index_dynamic_has_id(
     svs_index_h index, size_t id, bool* out_has_id, svs_error_h out_err
 ) {
diff --git a/include/svs/concurrent/dynamic_index.h b/include/svs/concurrent/dynamic_index.h
index 1fc1a89..7a6f87b 100644
--- a/include/svs/concurrent/dynamic_index.h
+++ b/include/svs/concurrent/dynamic_index.h
@@ -1617,6 +1617,87 @@ class MutableVamanaIndex {
         consolidate_locked();
     }
 
+    // EXPERIMENT: resumable consolidation. The first step snapshots the Deleted set
+    // and gathers its in-neighbors once; each step repairs up to `max_work` of them;
+    // the last step frees the slots as consolidate_locked() does. Returns true when
+    // the pass is complete (or there was nothing to do).
+    struct ConsolidationProgress {
+        tsl::robin_set<Idx> deleted{};
+        std::vector<size_t> work{};
+        size_t next = 0;
+        Idx anchor{};
+        bool active = false;
+    };
+    std::unique_ptr<ConsolidationProgress> progress_ =
+        std::make_unique<ConsolidationProgress>();
+    std::unique_ptr<std::mutex> progress_mutex_ = std::make_unique<std::mutex>();
+
+    bool consolidate_step(size_t max_work) {
+        auto compact_lock = lock_storage_shared();
+        std::lock_guard step_lock{*progress_mutex_};
+        auto& p = *progress_;
+        ConsolidationParameters params{200'000, prune_to_, max_candidates_, alpha_};
+        auto consolidator = GraphConsolidator{graph_, data_, threadpool_, distance_, params};
+        if (!p.active) {
+            p.deleted.clear();
+            for (size_t i = 0, imax = status_.size(); i < imax; ++i) {
+                if (std::atomic_ref<SlotMetadata>(status_[i])
+                        .load(std::memory_order_acquire) == SlotMetadata::Deleted) {
+                    p.deleted.insert(lib::narrow_cast<Idx>(i));
+                }
+            }
+            if (p.deleted.empty()) {
+                return true;
+            }
+            p.anchor = entry_point();
+            auto gather_remove = [&](size_t i) {
+                return i != p.anchor && p.deleted.contains(lib::narrow_cast<Idx>(i));
+            };
+            p.work = consolidator.gather_work_set(p.deleted, gather_remove);
+            p.next = 0;
+            p.active = true;
+        }
+        auto should_remove = [&](size_t i) {
+            return i != p.anchor && p.deleted.contains(lib::narrow_cast<Idx>(i));
+        };
+        const size_t end = std::min(p.work.size(), p.next + std::max<size_t>(max_work, 1));
+        std::vector<size_t> slice(p.work.begin() + p.next, p.work.begin() + end);
+        if (!slice.empty()) {
+            consolidator.run_driver(slice, should_remove);
+        }
+        p.next = end;
+        if (p.next < p.work.size()) {
+            return false;
+        }
+        {
+            std::lock_guard lock{*translator_mutex_};
+            std::vector<size_t> deleted_internal_ids;
+            for (auto i : p.deleted) {
+                if (status_[i] == SlotMetadata::Deleted && translator_.has_internal(i)) {
+                    deleted_internal_ids.push_back(i);
+                }
+            }
+            if (!deleted_internal_ids.empty()) {
+                translator_.delete_internal(deleted_internal_ids, false);
+            }
+            size_t min_freed = std::numeric_limits<size_t>::max();
+            for (auto i : p.deleted) {
+                if (i != p.anchor && status_[i] == SlotMetadata::Deleted) {
+                    std::atomic_ref<SlotMetadata>(status_[i])
+                        .store(SlotMetadata::Empty, std::memory_order_release);
+                    min_freed = std::min(min_freed, static_cast<size_t>(i));
+                }
+            }
+            if (min_freed != std::numeric_limits<size_t>::max()) {
+                detail::atomic_min(*first_reusable_, min_freed);
+            }
+        }
+        p.active = false;
+        p.deleted.clear();
+        p.work.clear();
+        return true;
+    }
+
     // Full consolidation: gather the Deleted set (one cheap byte scan of status_) then
     // delegate. Concurrent deletes arriving after the scan are picked up next round.
     void consolidate_locked() {
diff --git a/include/svs/orchestrators/dynamic_vamana.h b/include/svs/orchestrators/dynamic_vamana.h
index 261278d..f99eaa1 100644
--- a/include/svs/orchestrators/dynamic_vamana.h
+++ b/include/svs/orchestrators/dynamic_vamana.h
@@ -45,6 +45,8 @@ class DynamicVamanaInterface : public VamanaInterface {
 
     virtual void delete_points(std::span<const size_t> ids) = 0;
     virtual void consolidate() = 0;
+    // EXPERIMENT: one step of a resumable consolidation; true when complete.
+    virtual bool consolidate_step(size_t max_work) = 0;
     virtual void compact(size_t batchsize = 1'000'000) = 0;
 
     // ID inspection.
@@ -85,6 +87,13 @@ class DynamicVamanaImpl : public VamanaImpl<QueryTypes, Impl, DynamicVamanaInter
 
     void delete_points(std::span<const size_t> ids) override { impl().delete_entries(ids); }
     void consolidate() override { impl().consolidate(); }
+    bool consolidate_step(size_t max_work) override {
+        if constexpr (requires { impl().consolidate_step(max_work); }) {
+            return impl().consolidate_step(max_work);
+        } else {
+            throw ANNEXCEPTION("consolidate_step needs the concurrent index");
+        }
+    }
     void compact(size_t batchsize) override { impl().compact(batchsize); }
 
     // ID inspection.
@@ -177,6 +186,7 @@ class DynamicVamana : public manager::IndexManager<DynamicVamanaInterface> {
         impl_->consolidate();
         return *this;
     }
+    bool consolidate_step(size_t max_work) { return impl_->consolidate_step(max_work); }
 
     DynamicVamana& compact(size_t batchsize = 1'000'000) {
         impl_->compact(batchsize);

@eshenayo

Copy link
Copy Markdown

Follow-up to the comment above, with two corrections.

1. The prototype crashed. In one of three uniform-data runs, the valkey-server process died during the SAVE that follows the churn. At that point 13,395 slots were deleted and a resumable pass was probably in progress. My reading, not yet confirmed with a stack trace (that run kept no server log):

  • the pass's first step had already called gather_work_set, which reset_node()s the reverse-edge lists of the deleted nodes it gathered;
  • save() then ran its own consolidate + compact, which can miss in-neighbors whose reverse entries are gone;
  • the save then crashed.

So a resumable pass has to coordinate with save and compaction. Either finish or abandon the pending pass first, restoring the reverse entries it cleared, or have save and compaction skip the slots the pass holds. Treat the resumable numbers below as an estimate until that is fixed.

2. Repeated runs at about 98% recall. The earlier numbers were single runs at EF_RUNTIME 100. On uniform random data that gives recall@10 of only about 0.81, and QPS varied up to 2x between identical runs. These are 3 runs each at EF_RUNTIME 400, shown as median (min-max), plus the Cohere wiki embedding subset (8K live x 768-d) as real data.

inline consolidate + compact background consolidate + compact resumable, 4,096-node steps
uniform: worst single write 12.6 s (12.0-13.5) 8.7 s (8.2-11.8) not summarized (2 of 3 runs completed)
uniform: churn writes (ops/s) 369 (324-373) 431 (421-438) about 500 (2 runs)
uniform: recall@10 after churn 0.965 (0.961-0.971) 0.971 (0.971-0.977) 0.973, 0.976 (2 runs)
uniform: SAVE / BGSAVE COW 5.4 s / 106 MB 1.3 s / 83 MB 12.8-13.4 s / 162 MB (2 runs), 1 crash
Cohere: worst single write 2.4 s (2.2-2.5) 1.9 s (1.5-2.0) 56 ms (47-78)
Cohere: insert p99 60 ms 1.34 s (1.27-1.92) 52 ms
Cohere: recall@10 after churn 1.000 1.000 1.000
Cohere: memory growth over churn 0.9 MB 13.6 MB 15.7 MB

The conclusions from the earlier comment hold: a resumable pass, plus refilling freed slots, removes the multi-second pauses on both datasets, and saves need #421. The crash makes coordination with save and compaction a requirement for any real implementation, not an edge case.

@eshenayo

Copy link
Copy Markdown

Correction to the follow-up above: the "Cohere: insert p99" row (60 ms / 1.34 s / 52 ms) is misleading. It is not a regression in the background-consolidation build.

I timed every churn write and lined them up with the consolidation and compaction windows in the server log (Cohere, 8K live x 768-d, 8 writer clients):

  • Each pause stalls exactly the 8 writes in flight, one per writer client, for the whole pause. In the background-consolidation build, all 24 writes over 200 ms overlapped an exclusive compact(). Writes during the concurrent consolidate stayed at p99 24-43 ms.
  • That benchmark's "insert p99" was the worst p99 of any round, and a Cohere round has only 400 inserts, so 8 stalled writes are 2% of the round. The inline build's pauses happen to land in the delete and update phases (per-round p99 1.8-2.5 s there), and the background build's compaction landed in the insert phase.
  • Whole-run p99 per phase (delete / insert / update) is 35 / 39 / 44 ms inline against 31 / 36 / 37 ms with background consolidation. p99.9 is 2.0 s / 48 ms / 2.6 s against 71 ms / 1.4 s / 1.4 s. In other words, the pause moved from deletes and updates to inserts and updates, and got shorter (about 1.4 s against 2.0-2.6 s).

The benchmark now reports whole-run percentiles per phase. The other rows and conclusions above are unchanged: compaction is the remaining pause in that build, and the resumable pass removes it.

This branch has not been deployed

No deployments
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.

2 participants