Skip to content

Commit c6d634c

Browse files
Fix: Reclaim slot_lookup_ tombstones under churn (#769)
`remove` marks a slot deleted but leaves it populated, so probes walk past it. The resize decision only counted live entries, and under `remove`+`add` churn the live count stays flat, so the table never grew and never rehashed. Tombstones accumulated until no empty slot remained; probes could then no longer terminate, `equal_range` returned `end()` for a key that was still live, and `remove` took the empty-range branch reporting nothing removed. The entry became a ghost: `contains` kept finding it, `remove` silently no-oped, and `size()` drifted above the true live count. Closes #753 Co-Authored-By: Misha Chichvarin <6496186+desertfury@users.noreply.github.com> Co-Authored-By: Ash Vardanian <1983160+ashvardanian@users.noreply.github.com>
1 parent 9fc3500 commit c6d634c

2 files changed

Lines changed: 138 additions & 50 deletions

File tree

cpp/test.cpp

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1115,6 +1115,72 @@ template <typename key_at, typename slot_at> void test_strings() {
11151115
/**
11161116
* @brief Tests replacing and updating entries in index_dense_gt to ensure consistency after modifications.
11171117
*/
1118+
/**
1119+
* @brief Churns a tightly-reserved index, so `slot_lookup_` accumulates tombstones.
1120+
*
1121+
* Removals leave tombstones that probes must walk past. If they are never reclaimed the
1122+
* table runs out of empty slots, probes can no longer terminate, and live keys go missing:
1123+
* `remove` reports nothing removed while `contains` still finds the key. Copying such a
1124+
* table must rebuild the probe chains, not reproduce the slot layout.
1125+
*/
1126+
template <typename key_at, typename slot_at> void test_slot_lookup_churn() {
1127+
constexpr std::size_t live_count = 128;
1128+
constexpr std::size_t churn_count = live_count * 8;
1129+
constexpr std::size_t dimensions = 4;
1130+
1131+
using index_punned_t = index_dense_gt<key_at, slot_at>;
1132+
metric_punned_t metric(dimensions, metric_kind_t::cos_k);
1133+
1134+
std::random_device seed_source;
1135+
std::mt19937 generator(seed_source());
1136+
std::uniform_real_distribution<float> distribution(0.0, 1.0);
1137+
using vector_of_vectors_t = std::vector<std::vector<float>>;
1138+
1139+
vector_of_vectors_t vector_of_vectors(live_count + churn_count);
1140+
for (auto& vector : vector_of_vectors) {
1141+
vector.resize(dimensions);
1142+
std::generate(vector.begin(), vector.end(), [&] { return distribution(generator); });
1143+
}
1144+
1145+
index_punned_t index = index_punned_t::make(metric);
1146+
1147+
// Reserve tightly, so the churn below exhausts the empty slots
1148+
index.reserve(live_count * 3);
1149+
1150+
std::vector<key_at> live_keys;
1151+
std::size_t added = 0;
1152+
for (; added < live_count; ++added) {
1153+
index.add(static_cast<key_at>(added), vector_of_vectors[added].data());
1154+
live_keys.push_back(static_cast<key_at>(added));
1155+
}
1156+
1157+
// Every iteration frees one slot and immediately reuses it
1158+
for (std::size_t idx = 0; idx < churn_count; ++idx, ++added) {
1159+
key_at victim = live_keys[idx % live_count];
1160+
expect(index.contains(victim));
1161+
expect_eq(index.remove(victim).completed, 1);
1162+
expect(!index.contains(victim));
1163+
index.add(static_cast<key_at>(added), vector_of_vectors[added].data());
1164+
live_keys[idx % live_count] = static_cast<key_at>(added);
1165+
}
1166+
1167+
// Every live key stays reachable and the size never drifts
1168+
expect_eq(index.size(), live_count);
1169+
for (key_at key : live_keys)
1170+
expect(index.contains(key));
1171+
1172+
// Copying a table that still holds tombstones must not strand live keys
1173+
expect_eq(index.remove(live_keys.back()).completed, 1);
1174+
live_keys.pop_back();
1175+
1176+
auto copy_result = index.copy();
1177+
expect(copy_result);
1178+
index_punned_t& copy = copy_result.index;
1179+
expect_eq(copy.size(), live_keys.size());
1180+
for (key_at key : live_keys)
1181+
expect(copy.contains(key));
1182+
}
1183+
11181184
template <typename key_at, typename slot_at> void test_replacing_update() {
11191185

11201186
using vector_key_t = key_at;
@@ -1450,6 +1516,10 @@ int main(int, char**) {
14501516
test_sets<std::int64_t, slot32_t>(set_size, 20, 30);
14511517
test_strings<std::int64_t, slot32_t>();
14521518

1519+
std::printf("Testing key lookups under churn\n");
1520+
test_slot_lookup_churn<std::int64_t, std::uint32_t>();
1521+
test_slot_lookup_churn<std::int64_t, uint40_t>();
1522+
14531523
test_filtered_search();
14541524
test_isolate();
14551525
test_load_after_metric_make();

include/usearch/index_plugins.hpp

Lines changed: 68 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -3746,6 +3746,8 @@ class flat_hash_multi_set_gt {
37463746
char* data_ = nullptr;
37473747
std::size_t buckets_ = 0;
37483748
std::size_t populated_slots_ = 0;
3749+
/// @brief Number of tombstones (slots marked deleted but not yet reclaimed)
3750+
std::size_t deleted_slots_ = 0;
37493751
/// @brief Number of slots
37503752
std::size_t capacity_slots_ = 0;
37513753

@@ -3781,6 +3783,34 @@ class flat_hash_multi_set_gt {
37813783
}
37823784
}
37833785

3786+
/**
3787+
* @brief Copies every live entry of @p source into the freshly zeroed @p target.
3788+
*
3789+
* Tombstones are never carried over, so probe chains must be rebuilt from the hash
3790+
* rather than reproduced slot-for-slot: a live entry displaced past a tombstone by
3791+
* linear probing would otherwise become unreachable once that tombstone reads empty.
3792+
* The target holds no tombstones, so an unpopulated slot is always a free slot.
3793+
*/
3794+
void rehash_into(char* source, std::size_t source_slots, char* target, std::size_t target_slots) const noexcept {
3795+
hash_t hasher;
3796+
for (std::size_t i = 0; i != source_slots; ++i) {
3797+
slot_ref_t source_slot = slot_ref(source, i);
3798+
if (!(source_slot.header.populated & source_slot.mask) || (source_slot.header.deleted & source_slot.mask))
3799+
continue;
3800+
3801+
std::size_t target_index = hasher(source_slot.element) & (target_slots - 1);
3802+
while (true) {
3803+
slot_ref_t target_slot = slot_ref(target, target_index);
3804+
if (!(target_slot.header.populated & target_slot.mask)) {
3805+
new (&target_slot.element) element_t(source_slot.element);
3806+
target_slot.header.populated |= target_slot.mask;
3807+
break;
3808+
}
3809+
target_index = (target_index + 1) & (target_slots - 1);
3810+
}
3811+
}
3812+
}
3813+
37843814
public:
37853815
std::size_t size() const noexcept { return populated_slots_; }
37863816
std::size_t capacity() const noexcept { return capacity_slots_ * 2u / 3u; }
@@ -3805,22 +3835,15 @@ class flat_hash_multi_set_gt {
38053835
if (!data_)
38063836
usearch_raise_runtime_error("failed memory allocation");
38073837

3808-
// Copy metadata
3838+
// Copy metadata. Only live entries are rehashed below, so the copy has no tombstones.
38093839
buckets_ = other.buckets_;
38103840
populated_slots_ = other.populated_slots_;
3841+
deleted_slots_ = 0;
38113842
capacity_slots_ = other.capacity_slots_;
38123843

38133844
// Initialize new buckets to empty
38143845
std::memset(data_, 0, buckets_ * bytes_per_bucket());
3815-
3816-
// Copy elements and bucket headers
3817-
for (std::size_t i = 0; i < capacity_slots_; ++i) {
3818-
slot_ref_t old_slot = other.slot_ref(i);
3819-
if ((old_slot.header.populated & old_slot.mask) && !(old_slot.header.deleted & old_slot.mask)) {
3820-
slot_ref_t new_slot = slot_ref(i);
3821-
populate_slot(new_slot, old_slot.element);
3822-
}
3823-
}
3846+
rehash_into(other.data_, other.capacity_slots_, data_, capacity_slots_);
38243847
}
38253848

38263849
flat_hash_multi_set_gt& operator=(flat_hash_multi_set_gt const& other) {
@@ -3848,22 +3871,15 @@ class flat_hash_multi_set_gt {
38483871
if (!data_)
38493872
usearch_raise_runtime_error("failed memory allocation");
38503873

3851-
// Copy metadata
3874+
// Copy metadata. Only live entries are rehashed below, so the copy has no tombstones.
38523875
buckets_ = other.buckets_;
38533876
populated_slots_ = other.populated_slots_;
3877+
deleted_slots_ = 0;
38543878
capacity_slots_ = other.capacity_slots_;
38553879

38563880
// Initialize new buckets to empty
38573881
std::memset(data_, 0, buckets_ * bytes_per_bucket());
3858-
3859-
// Copy elements and bucket headers
3860-
for (std::size_t i = 0; i < capacity_slots_; ++i) {
3861-
slot_ref_t old_slot = other.slot_ref(i);
3862-
if ((old_slot.header.populated & old_slot.mask) && !(old_slot.header.deleted & old_slot.mask)) {
3863-
slot_ref_t new_slot = slot_ref(i);
3864-
populate_slot(new_slot, old_slot.element);
3865-
}
3866-
}
3882+
rehash_into(other.data_, other.capacity_slots_, data_, capacity_slots_);
38673883

38683884
return *this;
38693885
}
@@ -3880,6 +3896,7 @@ class flat_hash_multi_set_gt {
38803896
if (data_)
38813897
std::memset(data_, 0, buckets_ * bytes_per_bucket());
38823898
populated_slots_ = 0;
3899+
deleted_slots_ = 0;
38833900
}
38843901

38853902
void reset() noexcept {
@@ -3889,11 +3906,19 @@ class flat_hash_multi_set_gt {
38893906
data_ = nullptr;
38903907
buckets_ = 0;
38913908
populated_slots_ = 0;
3909+
deleted_slots_ = 0;
38923910
capacity_slots_ = 0;
38933911
}
38943912

3913+
/**
3914+
* @brief Grows the table to fit @p capacity live entries, reclaiming tombstones.
3915+
*
3916+
* Tombstones are reclaimed even when no growth is requested. They only ever stop
3917+
* being created once reclaimed, and a table with no empty slot left cannot
3918+
* terminate a probe, silently stranding live entries.
3919+
*/
38953920
bool try_reserve(std::size_t capacity) noexcept {
3896-
if (capacity <= this->capacity())
3921+
if (capacity <= this->capacity() && deleted_slots_ == 0)
38973922
return true;
38983923

38993924
// Calculate new sizes
@@ -3913,7 +3938,15 @@ class flat_hash_multi_set_gt {
39133938
checked_size_result_t new_slots = checked_mul(new_buckets_checked.value, slots_per_bucket());
39143939
if (!new_slots)
39153940
return false;
3916-
checked_size_result_t new_bytes = checked_mul(new_buckets_checked.value, bytes_per_bucket());
3941+
3942+
// Never shrink: reclaiming tombstones alone needs no more than the current capacity
3943+
std::size_t target_buckets = new_buckets_checked.value;
3944+
std::size_t target_slots = new_slots.value;
3945+
if (target_slots < capacity_slots_) {
3946+
target_buckets = buckets_;
3947+
target_slots = capacity_slots_;
3948+
}
3949+
checked_size_result_t new_bytes = checked_mul(target_buckets, bytes_per_bucket());
39173950
if (!new_bytes)
39183951
return false;
39193952

@@ -3924,36 +3957,15 @@ class flat_hash_multi_set_gt {
39243957

39253958
// Initialize new buckets to empty
39263959
std::memset(new_data, 0, new_bytes.value);
3927-
3928-
// Rehash and copy existing elements to new_data
3929-
hash_t hasher;
3930-
for (std::size_t i = 0; i < capacity_slots_; ++i) {
3931-
slot_ref_t old_slot = slot_ref(i);
3932-
if ((~old_slot.header.populated & old_slot.mask) | (old_slot.header.deleted & old_slot.mask))
3933-
continue;
3934-
3935-
// Rehash
3936-
std::size_t hash_value = hasher(old_slot.element);
3937-
std::size_t new_slot_index = hash_value & (new_slots.value - 1);
3938-
3939-
// Linear probing to find an empty slot in new_data
3940-
while (true) {
3941-
slot_ref_t new_slot = slot_ref(new_data, new_slot_index);
3942-
if (!(new_slot.header.populated & new_slot.mask) || (new_slot.header.deleted & new_slot.mask)) {
3943-
populate_slot(new_slot, std::move(old_slot.element));
3944-
new_slot.header.populated |= new_slot.mask;
3945-
break;
3946-
}
3947-
new_slot_index = (new_slot_index + 1) & (new_slots.value - 1);
3948-
}
3949-
}
3960+
rehash_into(data_, capacity_slots_, new_data, target_slots);
39503961

39513962
// Deallocate old data and update pointers and sizes
39523963
if (data_)
39533964
allocator_t{}.deallocate(data_, buckets_ * bytes_per_bucket());
39543965
data_ = new_data;
3955-
buckets_ = new_buckets_checked.value;
3956-
capacity_slots_ = new_slots.value;
3966+
buckets_ = target_buckets;
3967+
capacity_slots_ = target_slots;
3968+
deleted_slots_ = 0;
39573969

39583970
return true;
39593971
}
@@ -4082,6 +4094,7 @@ class flat_hash_multi_set_gt {
40824094
// Found a match, mark as deleted
40834095
slot.header.deleted |= slot.mask;
40844096
--populated_slots_;
4097+
++deleted_slots_;
40854098
popped_value = slot.element;
40864099
return true; // Successfully removed
40874100
}
@@ -4117,6 +4130,7 @@ class flat_hash_multi_set_gt {
41174130
// Found a match, mark as deleted
41184131
slot.header.deleted |= slot.mask;
41194132
--populated_slots_;
4133+
++deleted_slots_;
41204134
++count; // Increment count of elements removed
41214135
}
41224136
} else {
@@ -4247,8 +4261,10 @@ class flat_hash_multi_set_gt {
42474261
}
42484262

42494263
bool try_emplace(element_t const& element) noexcept {
4250-
// Check if we need to resize
4251-
if (populated_slots_ * 3u >= capacity_slots_ * 2u)
4264+
// Both live entries and tombstones consume slots a probe must walk past, so the
4265+
// load factor counts them together. Under churn the live count alone stays flat
4266+
// and would never trigger the rehash that reclaims the tombstones.
4267+
if ((populated_slots_ + deleted_slots_) * 3u >= capacity_slots_ * 2u)
42524268
if (!try_reserve(populated_slots_ + 1))
42534269
return false;
42544270

@@ -4260,7 +4276,9 @@ class flat_hash_multi_set_gt {
42604276
while (true) {
42614277
slot_ref_t slot = slot_ref(slot_index);
42624278
if ((~slot.header.populated & slot.mask) | (slot.header.deleted & slot.mask)) {
4263-
// Found an empty or deleted slot
4279+
// Found an empty or deleted slot; reusing a tombstone reclaims it.
4280+
// Read the tombstone bit before `populate_slot` clears it.
4281+
deleted_slots_ -= (slot.header.deleted & slot.mask) != 0;
42644282
populate_slot(slot, element);
42654283
++populated_slots_;
42664284
return true;

0 commit comments

Comments
 (0)