usearch 2.25.3 → 2.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/cpp/test.cpp +70 -0
- package/include/usearch/index.hpp +27 -2
- package/include/usearch/index_plugins.hpp +68 -50
- package/package.json +2 -2
- package/prebuilds/darwin-arm64+x64/usearch.node +0 -0
- package/prebuilds/linux-arm64/usearch.node +0 -0
- package/prebuilds/linux-x64/usearch.node +0 -0
- package/prebuilds/win32-x64/usearch.node +0 -0
package/README.md
CHANGED
package/cpp/test.cpp
CHANGED
|
@@ -1115,6 +1115,72 @@ template <typename key_at, typename slot_at> void test_strings() {
|
|
|
1115
1115
|
/**
|
|
1116
1116
|
* @brief Tests replacing and updating entries in index_dense_gt to ensure consistency after modifications.
|
|
1117
1117
|
*/
|
|
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
|
+
|
|
1118
1184
|
template <typename key_at, typename slot_at> void test_replacing_update() {
|
|
1119
1185
|
|
|
1120
1186
|
using vector_key_t = key_at;
|
|
@@ -1450,6 +1516,10 @@ int main(int, char**) {
|
|
|
1450
1516
|
test_sets<std::int64_t, slot32_t>(set_size, 20, 30);
|
|
1451
1517
|
test_strings<std::int64_t, slot32_t>();
|
|
1452
1518
|
|
|
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
|
+
|
|
1453
1523
|
test_filtered_search();
|
|
1454
1524
|
test_isolate();
|
|
1455
1525
|
test_load_after_metric_make();
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
#define UNUM_USEARCH_HPP
|
|
9
9
|
|
|
10
10
|
#define USEARCH_VERSION_MAJOR 2
|
|
11
|
-
#define USEARCH_VERSION_MINOR
|
|
12
|
-
#define USEARCH_VERSION_PATCH
|
|
11
|
+
#define USEARCH_VERSION_MINOR 26
|
|
12
|
+
#define USEARCH_VERSION_PATCH 0
|
|
13
13
|
|
|
14
14
|
// Inferring C++ version
|
|
15
15
|
// https://stackoverflow.com/a/61552074
|
|
@@ -1277,6 +1277,31 @@ template <typename element_at> struct hash_gt {
|
|
|
1277
1277
|
std::size_t operator()(element_at const& element) const noexcept { return std::hash<element_at>{}(element); }
|
|
1278
1278
|
};
|
|
1279
1279
|
|
|
1280
|
+
/**
|
|
1281
|
+
* @brief SplitMix64 finalizer, used to scatter integer keys before masking.
|
|
1282
|
+
*
|
|
1283
|
+
* On libstdc++ and libc++ `std::hash` is the identity for integers. Our open-addressing
|
|
1284
|
+
* tables mask that hash into a power-of-2 slot count and probe linearly until an @b empty
|
|
1285
|
+
* slot, so consecutive keys land in adjacent slots and merge into one contiguous run.
|
|
1286
|
+
* Both insertions and lookups then scan that run end-to-end, which is quadratic overall:
|
|
1287
|
+
* a dense ascending key range collapses insertion throughput by three orders of magnitude.
|
|
1288
|
+
* Mixing costs ~20ns per key and is dwarfed by the graph traversal in `add`.
|
|
1289
|
+
*/
|
|
1290
|
+
template <> struct hash_gt<std::uint64_t> {
|
|
1291
|
+
std::size_t operator()(std::uint64_t const& element) const noexcept {
|
|
1292
|
+
std::uint64_t x = element;
|
|
1293
|
+
x = (x ^ (x >> 30u)) * 0xBF58476D1CE4E5B9ULL;
|
|
1294
|
+
x = (x ^ (x >> 27u)) * 0x94D049BB133111EBULL;
|
|
1295
|
+
return static_cast<std::size_t>(x ^ (x >> 31u));
|
|
1296
|
+
}
|
|
1297
|
+
};
|
|
1298
|
+
|
|
1299
|
+
template <> struct hash_gt<std::int64_t> {
|
|
1300
|
+
std::size_t operator()(std::int64_t const& element) const noexcept {
|
|
1301
|
+
return hash_gt<std::uint64_t>{}(static_cast<std::uint64_t>(element));
|
|
1302
|
+
}
|
|
1303
|
+
};
|
|
1304
|
+
|
|
1280
1305
|
template <> struct hash_gt<uint40_t> {
|
|
1281
1306
|
std::size_t operator()(uint40_t const& element) const noexcept { return std::hash<std::size_t>{}(element); }
|
|
1282
1307
|
};
|
|
@@ -3746,6 +3746,8 @@ class flat_hash_multi_set_gt {
|
|
|
3746
3746
|
char* data_ = nullptr;
|
|
3747
3747
|
std::size_t buckets_ = 0;
|
|
3748
3748
|
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;
|
|
3749
3751
|
/// @brief Number of slots
|
|
3750
3752
|
std::size_t capacity_slots_ = 0;
|
|
3751
3753
|
|
|
@@ -3781,6 +3783,34 @@ class flat_hash_multi_set_gt {
|
|
|
3781
3783
|
}
|
|
3782
3784
|
}
|
|
3783
3785
|
|
|
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
|
+
|
|
3784
3814
|
public:
|
|
3785
3815
|
std::size_t size() const noexcept { return populated_slots_; }
|
|
3786
3816
|
std::size_t capacity() const noexcept { return capacity_slots_ * 2u / 3u; }
|
|
@@ -3805,22 +3835,15 @@ class flat_hash_multi_set_gt {
|
|
|
3805
3835
|
if (!data_)
|
|
3806
3836
|
usearch_raise_runtime_error("failed memory allocation");
|
|
3807
3837
|
|
|
3808
|
-
// Copy metadata
|
|
3838
|
+
// Copy metadata. Only live entries are rehashed below, so the copy has no tombstones.
|
|
3809
3839
|
buckets_ = other.buckets_;
|
|
3810
3840
|
populated_slots_ = other.populated_slots_;
|
|
3841
|
+
deleted_slots_ = 0;
|
|
3811
3842
|
capacity_slots_ = other.capacity_slots_;
|
|
3812
3843
|
|
|
3813
3844
|
// Initialize new buckets to empty
|
|
3814
3845
|
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_);
|
|
3824
3847
|
}
|
|
3825
3848
|
|
|
3826
3849
|
flat_hash_multi_set_gt& operator=(flat_hash_multi_set_gt const& other) {
|
|
@@ -3848,22 +3871,15 @@ class flat_hash_multi_set_gt {
|
|
|
3848
3871
|
if (!data_)
|
|
3849
3872
|
usearch_raise_runtime_error("failed memory allocation");
|
|
3850
3873
|
|
|
3851
|
-
// Copy metadata
|
|
3874
|
+
// Copy metadata. Only live entries are rehashed below, so the copy has no tombstones.
|
|
3852
3875
|
buckets_ = other.buckets_;
|
|
3853
3876
|
populated_slots_ = other.populated_slots_;
|
|
3877
|
+
deleted_slots_ = 0;
|
|
3854
3878
|
capacity_slots_ = other.capacity_slots_;
|
|
3855
3879
|
|
|
3856
3880
|
// Initialize new buckets to empty
|
|
3857
3881
|
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_);
|
|
3867
3883
|
|
|
3868
3884
|
return *this;
|
|
3869
3885
|
}
|
|
@@ -3880,6 +3896,7 @@ class flat_hash_multi_set_gt {
|
|
|
3880
3896
|
if (data_)
|
|
3881
3897
|
std::memset(data_, 0, buckets_ * bytes_per_bucket());
|
|
3882
3898
|
populated_slots_ = 0;
|
|
3899
|
+
deleted_slots_ = 0;
|
|
3883
3900
|
}
|
|
3884
3901
|
|
|
3885
3902
|
void reset() noexcept {
|
|
@@ -3889,11 +3906,19 @@ class flat_hash_multi_set_gt {
|
|
|
3889
3906
|
data_ = nullptr;
|
|
3890
3907
|
buckets_ = 0;
|
|
3891
3908
|
populated_slots_ = 0;
|
|
3909
|
+
deleted_slots_ = 0;
|
|
3892
3910
|
capacity_slots_ = 0;
|
|
3893
3911
|
}
|
|
3894
3912
|
|
|
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
|
+
*/
|
|
3895
3920
|
bool try_reserve(std::size_t capacity) noexcept {
|
|
3896
|
-
if (capacity <= this->capacity())
|
|
3921
|
+
if (capacity <= this->capacity() && deleted_slots_ == 0)
|
|
3897
3922
|
return true;
|
|
3898
3923
|
|
|
3899
3924
|
// Calculate new sizes
|
|
@@ -3913,7 +3938,15 @@ class flat_hash_multi_set_gt {
|
|
|
3913
3938
|
checked_size_result_t new_slots = checked_mul(new_buckets_checked.value, slots_per_bucket());
|
|
3914
3939
|
if (!new_slots)
|
|
3915
3940
|
return false;
|
|
3916
|
-
|
|
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());
|
|
3917
3950
|
if (!new_bytes)
|
|
3918
3951
|
return false;
|
|
3919
3952
|
|
|
@@ -3924,36 +3957,15 @@ class flat_hash_multi_set_gt {
|
|
|
3924
3957
|
|
|
3925
3958
|
// Initialize new buckets to empty
|
|
3926
3959
|
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);
|
|
3950
3961
|
|
|
3951
3962
|
// Deallocate old data and update pointers and sizes
|
|
3952
3963
|
if (data_)
|
|
3953
3964
|
allocator_t{}.deallocate(data_, buckets_ * bytes_per_bucket());
|
|
3954
3965
|
data_ = new_data;
|
|
3955
|
-
buckets_ =
|
|
3956
|
-
capacity_slots_ =
|
|
3966
|
+
buckets_ = target_buckets;
|
|
3967
|
+
capacity_slots_ = target_slots;
|
|
3968
|
+
deleted_slots_ = 0;
|
|
3957
3969
|
|
|
3958
3970
|
return true;
|
|
3959
3971
|
}
|
|
@@ -4082,6 +4094,7 @@ class flat_hash_multi_set_gt {
|
|
|
4082
4094
|
// Found a match, mark as deleted
|
|
4083
4095
|
slot.header.deleted |= slot.mask;
|
|
4084
4096
|
--populated_slots_;
|
|
4097
|
+
++deleted_slots_;
|
|
4085
4098
|
popped_value = slot.element;
|
|
4086
4099
|
return true; // Successfully removed
|
|
4087
4100
|
}
|
|
@@ -4117,6 +4130,7 @@ class flat_hash_multi_set_gt {
|
|
|
4117
4130
|
// Found a match, mark as deleted
|
|
4118
4131
|
slot.header.deleted |= slot.mask;
|
|
4119
4132
|
--populated_slots_;
|
|
4133
|
+
++deleted_slots_;
|
|
4120
4134
|
++count; // Increment count of elements removed
|
|
4121
4135
|
}
|
|
4122
4136
|
} else {
|
|
@@ -4247,8 +4261,10 @@ class flat_hash_multi_set_gt {
|
|
|
4247
4261
|
}
|
|
4248
4262
|
|
|
4249
4263
|
bool try_emplace(element_t const& element) noexcept {
|
|
4250
|
-
//
|
|
4251
|
-
|
|
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)
|
|
4252
4268
|
if (!try_reserve(populated_slots_ + 1))
|
|
4253
4269
|
return false;
|
|
4254
4270
|
|
|
@@ -4260,7 +4276,9 @@ class flat_hash_multi_set_gt {
|
|
|
4260
4276
|
while (true) {
|
|
4261
4277
|
slot_ref_t slot = slot_ref(slot_index);
|
|
4262
4278
|
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;
|
|
4264
4282
|
populate_slot(slot, element);
|
|
4265
4283
|
++populated_slots_;
|
|
4266
4284
|
return true;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "usearch",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.26.0",
|
|
4
4
|
"description": "Smaller & Faster Single-File Vector Search Engine from Unum",
|
|
5
5
|
"author": "Ash Vardanian (https://ashvardanian.com/)",
|
|
6
6
|
"license": "Apache 2.0",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"email": "info@unum.cloud"
|
|
15
15
|
},
|
|
16
16
|
"engines": {
|
|
17
|
-
"node": ">=
|
|
17
|
+
"node": ">=22"
|
|
18
18
|
},
|
|
19
19
|
"files": [
|
|
20
20
|
"binding.gyp",
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|