usearch 2.25.3 → 2.26.1
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 +2 -2
- package/cpp/test.cpp +134 -1
- package/include/usearch/index.hpp +31 -2
- package/include/usearch/index_dense.hpp +16 -5
- 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
|
@@ -545,11 +545,11 @@ index = Index(ndim=ndim, metric=CompiledMetric(
|
|
|
545
545
|
|
|
546
546
|
```bibtex
|
|
547
547
|
@software{Vardanian_USearch,
|
|
548
|
-
doi = {10.5281/zenodo.
|
|
548
|
+
doi = {10.5281/zenodo.7949415},
|
|
549
549
|
author = {Vardanian, Ash},
|
|
550
550
|
title = {{USearch by Unum Cloud}},
|
|
551
551
|
url = {https://github.com/unum-cloud/USearch},
|
|
552
|
-
version = {2.
|
|
552
|
+
version = {2.26.1},
|
|
553
553
|
year = {2026},
|
|
554
554
|
}
|
|
555
555
|
```
|
package/cpp/test.cpp
CHANGED
|
@@ -1113,7 +1113,74 @@ template <typename key_at, typename slot_at> void test_strings() {
|
|
|
1113
1113
|
}
|
|
1114
1114
|
|
|
1115
1115
|
/**
|
|
1116
|
-
*
|
|
1116
|
+
* @brief Churns a tightly-reserved index, so `slot_lookup_` accumulates tombstones.
|
|
1117
|
+
*
|
|
1118
|
+
* Removals leave tombstones that probes must walk past. If they are never reclaimed the
|
|
1119
|
+
* table runs out of empty slots, probes can no longer terminate, and live keys go missing:
|
|
1120
|
+
* `remove` reports nothing removed while `contains` still finds the key. Copying such a
|
|
1121
|
+
* table must rebuild the probe chains, not reproduce the slot layout.
|
|
1122
|
+
*/
|
|
1123
|
+
template <typename key_at, typename slot_at> void test_slot_lookup_churn() {
|
|
1124
|
+
constexpr std::size_t live_count = 128;
|
|
1125
|
+
constexpr std::size_t churn_count = live_count * 8;
|
|
1126
|
+
constexpr std::size_t dimensions = 4;
|
|
1127
|
+
|
|
1128
|
+
using index_punned_t = index_dense_gt<key_at, slot_at>;
|
|
1129
|
+
metric_punned_t metric(dimensions, metric_kind_t::cos_k);
|
|
1130
|
+
|
|
1131
|
+
std::random_device seed_source;
|
|
1132
|
+
std::mt19937 generator(seed_source());
|
|
1133
|
+
std::uniform_real_distribution<float> distribution(0.0, 1.0);
|
|
1134
|
+
using vector_of_vectors_t = std::vector<std::vector<float>>;
|
|
1135
|
+
|
|
1136
|
+
vector_of_vectors_t vector_of_vectors(live_count + churn_count);
|
|
1137
|
+
for (auto& vector : vector_of_vectors) {
|
|
1138
|
+
vector.resize(dimensions);
|
|
1139
|
+
std::generate(vector.begin(), vector.end(), [&] { return distribution(generator); });
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
index_punned_t index = index_punned_t::make(metric);
|
|
1143
|
+
|
|
1144
|
+
// Reserve tightly, so the churn below exhausts the empty slots
|
|
1145
|
+
index.reserve(live_count * 3);
|
|
1146
|
+
|
|
1147
|
+
std::vector<key_at> live_keys;
|
|
1148
|
+
std::size_t added = 0;
|
|
1149
|
+
for (; added < live_count; ++added) {
|
|
1150
|
+
index.add(static_cast<key_at>(added), vector_of_vectors[added].data());
|
|
1151
|
+
live_keys.push_back(static_cast<key_at>(added));
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// Every iteration frees one slot and immediately reuses it
|
|
1155
|
+
for (std::size_t idx = 0; idx < churn_count; ++idx, ++added) {
|
|
1156
|
+
key_at victim = live_keys[idx % live_count];
|
|
1157
|
+
expect(index.contains(victim));
|
|
1158
|
+
expect_eq(index.remove(victim).completed, 1);
|
|
1159
|
+
expect(!index.contains(victim));
|
|
1160
|
+
index.add(static_cast<key_at>(added), vector_of_vectors[added].data());
|
|
1161
|
+
live_keys[idx % live_count] = static_cast<key_at>(added);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// Every live key stays reachable and the size never drifts
|
|
1165
|
+
expect_eq(index.size(), live_count);
|
|
1166
|
+
for (key_at key : live_keys)
|
|
1167
|
+
expect(index.contains(key));
|
|
1168
|
+
|
|
1169
|
+
// Copying a table that still holds tombstones must not strand live keys
|
|
1170
|
+
expect_eq(index.remove(live_keys.back()).completed, 1);
|
|
1171
|
+
live_keys.pop_back();
|
|
1172
|
+
|
|
1173
|
+
auto copy_result = index.copy();
|
|
1174
|
+
expect(copy_result);
|
|
1175
|
+
index_punned_t& copy = copy_result.index;
|
|
1176
|
+
expect_eq(copy.size(), live_keys.size());
|
|
1177
|
+
for (key_at key : live_keys)
|
|
1178
|
+
expect(copy.contains(key));
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
/**
|
|
1182
|
+
* @brief Tests replacing and updating entries in `index_dense_gt`, to ensure consistency
|
|
1183
|
+
* after modifications.
|
|
1117
1184
|
*/
|
|
1118
1185
|
template <typename key_at, typename slot_at> void test_replacing_update() {
|
|
1119
1186
|
|
|
@@ -1361,6 +1428,67 @@ void test_load_after_metric_make() {
|
|
|
1361
1428
|
std::remove(path);
|
|
1362
1429
|
}
|
|
1363
1430
|
|
|
1431
|
+
/**
|
|
1432
|
+
* @brief Feeds `view` a truncated index file.
|
|
1433
|
+
*
|
|
1434
|
+
* The matrix dimensions and the node count come from the file itself, so a short file
|
|
1435
|
+
* describes a matrix and a level array that reach past the mapping. Both must be rejected
|
|
1436
|
+
* rather than walked, and a caller-supplied offset past the end must fail the same way.
|
|
1437
|
+
*/
|
|
1438
|
+
void test_view_of_truncated_file() {
|
|
1439
|
+
std::printf("Testing view of a truncated index file\n");
|
|
1440
|
+
|
|
1441
|
+
using index_t = index_dense_gt<std::int64_t, std::uint32_t>;
|
|
1442
|
+
std::size_t const dimensions = 64;
|
|
1443
|
+
std::size_t const collection = 2048;
|
|
1444
|
+
char const* path = "tmp_truncated.usearch";
|
|
1445
|
+
|
|
1446
|
+
metric_punned_t metric(dimensions, metric_kind_t::l2sq_k, scalar_kind<f32_t>());
|
|
1447
|
+
index_t::state_result_t built = index_t::make(metric);
|
|
1448
|
+
expect(built);
|
|
1449
|
+
expect(built.index.try_reserve(collection));
|
|
1450
|
+
std::vector<float> vector(dimensions);
|
|
1451
|
+
for (std::size_t i = 0; i != collection; ++i) {
|
|
1452
|
+
for (std::size_t d = 0; d != dimensions; ++d)
|
|
1453
|
+
vector[d] = static_cast<float>((i * 31 + d) % 997) / 997.f;
|
|
1454
|
+
expect(built.index.add(static_cast<std::int64_t>(i), vector.data()));
|
|
1455
|
+
}
|
|
1456
|
+
expect(built.index.save(path));
|
|
1457
|
+
|
|
1458
|
+
std::vector<char> prefix;
|
|
1459
|
+
{
|
|
1460
|
+
std::FILE* file = std::fopen(path, "rb");
|
|
1461
|
+
expect(file != nullptr);
|
|
1462
|
+
std::fseek(file, 0, SEEK_END);
|
|
1463
|
+
long const bytes = std::ftell(file);
|
|
1464
|
+
std::fseek(file, 0, SEEK_SET);
|
|
1465
|
+
prefix.resize(static_cast<std::size_t>(bytes) / 8);
|
|
1466
|
+
expect(std::fread(prefix.data(), 1, prefix.size(), file) == prefix.size());
|
|
1467
|
+
std::fclose(file);
|
|
1468
|
+
}
|
|
1469
|
+
{
|
|
1470
|
+
std::FILE* file = std::fopen(path, "wb");
|
|
1471
|
+
expect(file != nullptr);
|
|
1472
|
+
std::fwrite(prefix.data(), 1, prefix.size(), file);
|
|
1473
|
+
std::fclose(file);
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
index_t::state_result_t viewed = index_t::make(metric);
|
|
1477
|
+
expect(viewed);
|
|
1478
|
+
serialization_result_t truncated = viewed.index.view(path);
|
|
1479
|
+
expect(!truncated);
|
|
1480
|
+
truncated.error.release();
|
|
1481
|
+
|
|
1482
|
+
// A caller-supplied offset past the end must fail the same way.
|
|
1483
|
+
index_t::state_result_t offset_viewed = index_t::make(metric);
|
|
1484
|
+
expect(offset_viewed);
|
|
1485
|
+
serialization_result_t beyond = offset_viewed.index.view(memory_mapped_file_t(path), prefix.size() * 4);
|
|
1486
|
+
expect(!beyond);
|
|
1487
|
+
beyond.error.release();
|
|
1488
|
+
|
|
1489
|
+
std::remove(path);
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1364
1492
|
int main(int, char**) {
|
|
1365
1493
|
install_crash_handlers();
|
|
1366
1494
|
|
|
@@ -1450,8 +1578,13 @@ int main(int, char**) {
|
|
|
1450
1578
|
test_sets<std::int64_t, slot32_t>(set_size, 20, 30);
|
|
1451
1579
|
test_strings<std::int64_t, slot32_t>();
|
|
1452
1580
|
|
|
1581
|
+
std::printf("Testing key lookups under churn\n");
|
|
1582
|
+
test_slot_lookup_churn<std::int64_t, std::uint32_t>();
|
|
1583
|
+
test_slot_lookup_churn<std::int64_t, uint40_t>();
|
|
1584
|
+
|
|
1453
1585
|
test_filtered_search();
|
|
1454
1586
|
test_isolate();
|
|
1455
1587
|
test_load_after_metric_make();
|
|
1588
|
+
test_view_of_truncated_file();
|
|
1456
1589
|
return 0;
|
|
1457
1590
|
}
|
|
@@ -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 1
|
|
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
|
};
|
|
@@ -3945,6 +3970,10 @@ class index_gt {
|
|
|
3945
3970
|
: checked_size_overflow();
|
|
3946
3971
|
if (!first_offset)
|
|
3947
3972
|
return result.failed("Index is too large");
|
|
3973
|
+
if (file.size() < first_offset.value) {
|
|
3974
|
+
reset();
|
|
3975
|
+
return result.failed("File is corrupted and can't fit the node levels");
|
|
3976
|
+
}
|
|
3948
3977
|
offsets[0u] = first_offset.value;
|
|
3949
3978
|
for (std::size_t i = 1; i < header_size.value; ++i) {
|
|
3950
3979
|
checked_size_result_t next_offset = checked_add(offsets[i - 1], node_bytes_(levels[i - 1]));
|
|
@@ -1050,17 +1050,18 @@ class index_dense_gt {
|
|
|
1050
1050
|
*/
|
|
1051
1051
|
bool try_reserve(index_limits_t limits) {
|
|
1052
1052
|
|
|
1053
|
+
// Reserving is monotonic: reducing thread limits must not shrink member-addressed storage.
|
|
1054
|
+
limits.members = (std::max)(limits.members, typed_->capacity());
|
|
1055
|
+
limits.members = (std::max)(limits.members, vectors_lookup_.size());
|
|
1056
|
+
|
|
1053
1057
|
// The slot lookup system will generally prefer power-of-two sizes.
|
|
1054
1058
|
if (config_.enable_key_lookups) {
|
|
1055
1059
|
unique_lock_t lock(slot_lookup_mutex_);
|
|
1056
1060
|
if (!slot_lookup_.try_reserve(limits.members))
|
|
1057
1061
|
return false;
|
|
1058
|
-
limits.members = slot_lookup_.capacity();
|
|
1059
1062
|
}
|
|
1060
1063
|
|
|
1061
|
-
//
|
|
1062
|
-
// target for the `vectors_lookup_` to synchronize allocations and
|
|
1063
|
-
// expensive index re-organizations.
|
|
1064
|
+
// Hash-table load-factor slack does not need corresponding vector or graph slots.
|
|
1064
1065
|
if (limits.members != vectors_lookup_.size()) {
|
|
1065
1066
|
vectors_lookup_t new_vectors_lookup(limits.members);
|
|
1066
1067
|
if (!new_vectors_lookup)
|
|
@@ -1358,6 +1359,11 @@ class index_dense_gt {
|
|
|
1358
1359
|
if (!result)
|
|
1359
1360
|
return result;
|
|
1360
1361
|
|
|
1362
|
+
// `offset` is caller-supplied, so every `file.size() - offset` below would
|
|
1363
|
+
// otherwise underflow into a huge span instead of failing.
|
|
1364
|
+
if (offset > file.size())
|
|
1365
|
+
return result.failed("File is corrupted and lacks matrix dimensions");
|
|
1366
|
+
|
|
1361
1367
|
// Infer the new index size
|
|
1362
1368
|
std::uint64_t matrix_rows = 0;
|
|
1363
1369
|
std::uint64_t matrix_cols = 0;
|
|
@@ -1383,7 +1389,12 @@ class index_dense_gt {
|
|
|
1383
1389
|
matrix_cols = dimensions[1];
|
|
1384
1390
|
offset += sizeof(dimensions);
|
|
1385
1391
|
}
|
|
1386
|
-
|
|
1392
|
+
// bound the vectors matrix span against the file (overflow-safe; offset <= file.size() holds here)
|
|
1393
|
+
checked_size_result_t vectors_bytes =
|
|
1394
|
+
checked_mul(static_cast<std::size_t>(matrix_rows), static_cast<std::size_t>(matrix_cols));
|
|
1395
|
+
if (!vectors_bytes || file.size() - offset < vectors_bytes.value)
|
|
1396
|
+
return result.failed("File is corrupted: vectors matrix exceeds file size");
|
|
1397
|
+
vectors_buffer = {file.data() + offset, vectors_bytes.value};
|
|
1387
1398
|
offset += vectors_buffer.size();
|
|
1388
1399
|
}
|
|
1389
1400
|
|
|
@@ -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.1",
|
|
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
|