usearch 0.18.6 → 0.19.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/Cargo.toml +1 -1
- package/README.md +184 -11
- package/conanfile.py +1 -1
- package/cpp/bench.cpp +2 -2
- package/cpp/test.cpp +11 -9
- package/docs/compilation.md +4 -2
- package/include/usearch/index.hpp +269 -50
- package/include/usearch/index_punned_dense.hpp +123 -108
- package/include/usearch/index_punned_helpers.hpp +5 -13
- package/package.json +1 -1
- package/python/lib.cpp +116 -40
- package/python/scripts/bench.ipynb +4 -4
- package/python/scripts/bench.py +51 -28
- package/python/scripts/index_faiss.py +19 -19
- package/python/scripts/test.py +275 -56
- package/python/usearch/eval.py +100 -49
- package/python/usearch/index.py +118 -88
- package/python/usearch/io.py +23 -23
- package/python/usearch/join.py +2 -4
- package/python/usearch/numba.py +44 -30
- package/python/usearch/server.py +38 -26
- package/simsimd/.vscode/settings.json +2 -1
- package/simsimd/CMakeLists.txt +1 -1
- package/simsimd/README.md +14 -2
- package/simsimd/bench.cxx +44 -27
- package/simsimd/include/simsimd/simsimd.h +22 -7
- package/simsimd/include/simsimd/simsimd.hpp +0 -443
package/Cargo.toml
CHANGED
package/README.md
CHANGED
|
@@ -69,11 +69,13 @@ Both USearch and FAISS implement the same HNSW algorithm.
|
|
|
69
69
|
But they differ in a lot of design decisions.
|
|
70
70
|
USearch is designed to be compact and broadly compatible without sacrificing performance.
|
|
71
71
|
|
|
72
|
-
|
|
|
73
|
-
|
|
|
74
|
-
|
|
|
75
|
-
|
|
|
76
|
-
|
|
|
72
|
+
| | FAISS, `f32` | USearch, `f32` | USearch, `f16` | USearch, `f8` |
|
|
73
|
+
| :----------- | -----------: | -------------: | -------------: | ------------: |
|
|
74
|
+
| Batch Insert | 16 K/s | 73 K/s | 100 K/s | **104 K/s** |
|
|
75
|
+
| Batch Search | 82 K/s | 103 K/s | 113 K/s | **134 K/s** |
|
|
76
|
+
| Bulk Insert | 76 K/s | 105 K/s | 115 K/s | **202 K/s** |
|
|
77
|
+
| Bulk Search | 118 K/s | 174 K/s | 173 K/s | **304 K/s** |
|
|
78
|
+
| Recall @1 | 99% | 99.2% | 99.1% | **99.2%** |
|
|
77
79
|
|
|
78
80
|
> Dataset: 1M vectors sample of the Deep1B dataset.
|
|
79
81
|
> Hardware: `c7g.metal` AWS instance with 64 cores and DDR5 memory.
|
|
@@ -81,6 +83,7 @@ USearch is designed to be compact and broadly compatible without sacrificing per
|
|
|
81
83
|
> connectivity `M=16`,
|
|
82
84
|
> expansion @ construction `efConstruction=128`,
|
|
83
85
|
> and expansion @ search `ef=64`.
|
|
86
|
+
> Batch size is 256.
|
|
84
87
|
> Both libraries were compiled for the target architecture.
|
|
85
88
|
> Jump to the [Performance Tuning][benchmarking] section to read about the effects of those hyper-parameters.
|
|
86
89
|
|
|
@@ -248,7 +251,7 @@ index = Index(
|
|
|
248
251
|
expansion_search=64, # Control the quality of search, optional
|
|
249
252
|
)
|
|
250
253
|
|
|
251
|
-
vector = np.array([0.2, 0.6, 0.4]
|
|
254
|
+
vector = np.array([0.2, 0.6, 0.4])
|
|
252
255
|
index.add(42, vector)
|
|
253
256
|
matches, distances, count = index.search(vector, 10)
|
|
254
257
|
|
|
@@ -256,6 +259,7 @@ assert len(index) == 1
|
|
|
256
259
|
assert count == 1
|
|
257
260
|
assert matches[0] == 42
|
|
258
261
|
assert distances[0] <= 0.001
|
|
262
|
+
assert np.allclose(index[42], vector)
|
|
259
263
|
```
|
|
260
264
|
|
|
261
265
|
Python bindings are implemented with [`pybind/pybind11`](https://github.com/pybind/pybind11).
|
|
@@ -269,6 +273,13 @@ index.load('index.usearch') # Copy the whole index into memory
|
|
|
269
273
|
index.view('index.usearch') # View from disk without loading in memory
|
|
270
274
|
```
|
|
271
275
|
|
|
276
|
+
If you don't know anything about the index except its path, there are two more endpoints to know:
|
|
277
|
+
|
|
278
|
+
```py
|
|
279
|
+
Index.metadata('index.usearch') -> IndexMetadata
|
|
280
|
+
Index.restore('index.usearch', view=False) -> Index
|
|
281
|
+
```
|
|
282
|
+
|
|
272
283
|
#### Batch Operations
|
|
273
284
|
|
|
274
285
|
Adding or querying a batch of entries is identical to adding a single vector.
|
|
@@ -291,7 +302,9 @@ The first controls the number of threads spawned for the task.
|
|
|
291
302
|
The second controls whether the vector itself will be persisted inside the index.
|
|
292
303
|
If you can preserve the lifetime of the vector somewhere else, you can avoid the copy.
|
|
293
304
|
|
|
294
|
-
#### User-Defined Metrics in Python
|
|
305
|
+
#### User-Defined Metrics and JIT in Python
|
|
306
|
+
|
|
307
|
+
##### [Numba][numba]
|
|
295
308
|
|
|
296
309
|
Assuming the language boundary exists between Python user code and C++ implementation, there are more efficient solutions than passing a Python callable to the engine.
|
|
297
310
|
Luckily, with the help of [Numba][numba], we can JIT compile a function with a matching signature and pass it down to the engine.
|
|
@@ -305,7 +318,7 @@ signature = types.float32(
|
|
|
305
318
|
types.CPointer(types.float32))
|
|
306
319
|
|
|
307
320
|
@cfunc(signature)
|
|
308
|
-
def
|
|
321
|
+
def inner_product(a, b):
|
|
309
322
|
a_array = carray(a, ndim)
|
|
310
323
|
b_array = carray(b, ndim)
|
|
311
324
|
c = 0.0
|
|
@@ -313,19 +326,157 @@ def python_dot(a, b):
|
|
|
313
326
|
c += a_array[i] * b_array[i]
|
|
314
327
|
return 1 - c
|
|
315
328
|
|
|
316
|
-
index = Index(ndim=ndim, metric=
|
|
329
|
+
index = Index(ndim=ndim, metric=CompiledMetric(
|
|
330
|
+
pointer=inner_product.address,
|
|
331
|
+
kind=MetricKind.IP,
|
|
332
|
+
signature=MetricSignature.ArrayArray,
|
|
333
|
+
))
|
|
317
334
|
```
|
|
318
335
|
|
|
319
|
-
|
|
336
|
+
Alternatively, you can avoid pre-defining the number of dimensions, and pass it separately:
|
|
337
|
+
|
|
338
|
+
```py
|
|
339
|
+
signature = types.float32(
|
|
340
|
+
types.CPointer(types.float32),
|
|
341
|
+
types.CPointer(types.float32),
|
|
342
|
+
types.uint64)
|
|
343
|
+
|
|
344
|
+
@cfunc(signature)
|
|
345
|
+
def inner_product(a, b, ndim):
|
|
346
|
+
a_array = carray(a, ndim)
|
|
347
|
+
b_array = carray(b, ndim)
|
|
348
|
+
c = 0.0
|
|
349
|
+
for i in range(ndim):
|
|
350
|
+
c += a_array[i] * b_array[i]
|
|
351
|
+
return 1 - c
|
|
352
|
+
|
|
353
|
+
index = Index(ndim=ndim, metric=CompiledMetric(
|
|
354
|
+
pointer=inner_product.address,
|
|
355
|
+
kind=MetricKind.IP,
|
|
356
|
+
signature=MetricSignature.ArrayArraySize,
|
|
357
|
+
))
|
|
358
|
+
```
|
|
320
359
|
|
|
321
360
|
```sh
|
|
322
|
-
pip install
|
|
361
|
+
pip install numba
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
##### [Cppyy][cppyy]
|
|
365
|
+
|
|
366
|
+
Similarly, you can use Cppyy with Cling to JIT-compile native C or C++ code and pass it to USearch, which may be a good idea, if you want to explicitly request loop-unrolling or other low-level optimizations!
|
|
367
|
+
|
|
368
|
+
```py
|
|
369
|
+
import cppyy
|
|
370
|
+
import cppyy.ll
|
|
371
|
+
|
|
372
|
+
cppyy.cppdef("""
|
|
373
|
+
float inner_product(float *a, float *b) {
|
|
374
|
+
float result = 0;
|
|
375
|
+
#pragma unroll
|
|
376
|
+
for (size_t i = 0; i != ndim; ++i)
|
|
377
|
+
result += a[i] * b[i];
|
|
378
|
+
return 1 - result;
|
|
379
|
+
}
|
|
380
|
+
""".replace("ndim", str(ndim)))
|
|
381
|
+
|
|
382
|
+
function = cppyy.gbl.inner_product
|
|
383
|
+
index = Index(ndim=ndim, metric=CompiledMetric(
|
|
384
|
+
pointer=cppyy.ll.addressof(function),
|
|
385
|
+
kind=MetricKind.IP,
|
|
386
|
+
signature=MetricSignature.ArrayArraySize,
|
|
387
|
+
))
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
##### [PeachPy][peachpy]
|
|
391
|
+
|
|
392
|
+
We have covered JIT-ing Python with Numba and C++ with Cppyy and Cling.
|
|
393
|
+
How about writing Assembly directly?
|
|
394
|
+
That is also possible.
|
|
395
|
+
Below is an example of constructing the "Inner Product" distance for 8-dimensional `f32` vectors for x86 using [PeachPy][peachpy].
|
|
396
|
+
|
|
397
|
+
```py
|
|
398
|
+
from peachpy import (
|
|
399
|
+
Argument,
|
|
400
|
+
ptr,
|
|
401
|
+
float_,
|
|
402
|
+
const_float_,
|
|
403
|
+
)
|
|
404
|
+
from peachpy.x86_64 import (
|
|
405
|
+
abi,
|
|
406
|
+
Function,
|
|
407
|
+
uarch,
|
|
408
|
+
isa,
|
|
409
|
+
GeneralPurposeRegister64,
|
|
410
|
+
LOAD,
|
|
411
|
+
YMMRegister,
|
|
412
|
+
VSUBPS,
|
|
413
|
+
VADDPS,
|
|
414
|
+
VHADDPS,
|
|
415
|
+
VMOVUPS,
|
|
416
|
+
VFMADD231PS,
|
|
417
|
+
VPERM2F128,
|
|
418
|
+
VXORPS,
|
|
419
|
+
RETURN,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
a = Argument(ptr(const_float_), name="a")
|
|
423
|
+
b = Argument(ptr(const_float_), name="b")
|
|
424
|
+
|
|
425
|
+
with Function(
|
|
426
|
+
"inner_product", (a, b), float_, target=uarch.default + isa.avx2
|
|
427
|
+
) as asm_function:
|
|
428
|
+
|
|
429
|
+
# Request two 64-bit general-purpose registers for addresses
|
|
430
|
+
reg_a, reg_b = GeneralPurposeRegister64(), GeneralPurposeRegister64()
|
|
431
|
+
LOAD.ARGUMENT(reg_a, a)
|
|
432
|
+
LOAD.ARGUMENT(reg_b, b)
|
|
433
|
+
|
|
434
|
+
# Load the vectors
|
|
435
|
+
ymm_a = YMMRegister()
|
|
436
|
+
ymm_b = YMMRegister()
|
|
437
|
+
VMOVUPS(ymm_a, [reg_a])
|
|
438
|
+
VMOVUPS(ymm_b, [reg_b])
|
|
439
|
+
|
|
440
|
+
# Prepare the accumulator
|
|
441
|
+
ymm_c = YMMRegister()
|
|
442
|
+
ymm_one = YMMRegister()
|
|
443
|
+
VXORPS(ymm_c, ymm_c, ymm_c)
|
|
444
|
+
VXORPS(ymm_one, ymm_one, ymm_one)
|
|
445
|
+
|
|
446
|
+
# Accumulate A and B product into C
|
|
447
|
+
VFMADD231PS(ymm_c, ymm_a, ymm_b)
|
|
448
|
+
|
|
449
|
+
# Reduce the contents of a YMM register
|
|
450
|
+
ymm_c_permuted = YMMRegister()
|
|
451
|
+
VPERM2F128(ymm_c_permuted, ymm_c, ymm_c, 1)
|
|
452
|
+
VADDPS(ymm_c, ymm_c, ymm_c_permuted)
|
|
453
|
+
VHADDPS(ymm_c, ymm_c, ymm_c)
|
|
454
|
+
VHADDPS(ymm_c, ymm_c, ymm_c)
|
|
455
|
+
|
|
456
|
+
# Negate the values, to go from "similarity" to "distance"
|
|
457
|
+
VSUBPS(ymm_c, ymm_one, ymm_c)
|
|
458
|
+
|
|
459
|
+
# A common convention is to return floats in XMM registers
|
|
460
|
+
RETURN(ymm_c.as_xmm)
|
|
461
|
+
|
|
462
|
+
python_function = asm_function.finalize(abi.detect()).encode().load()
|
|
463
|
+
metric = CompiledMetric(
|
|
464
|
+
pointer=python_function.loader.code_address,
|
|
465
|
+
kind=MetricKind.IP,
|
|
466
|
+
signature=MetricSignature.ArrayArray,
|
|
467
|
+
)
|
|
468
|
+
index = Index(ndim=ndim, metric=metric)
|
|
323
469
|
```
|
|
324
470
|
|
|
325
471
|
[numba]: https://numba.readthedocs.io/en/stable/reference/jit-compilation.html#c-callbacks
|
|
472
|
+
[cppyy]: https://cppyy.readthedocs.io/en/latest/
|
|
473
|
+
[peachpy]: https://github.com/Maratyszcza/PeachPy
|
|
326
474
|
|
|
327
475
|
#### Tooling
|
|
328
476
|
|
|
477
|
+
To work with `bbin`, `fbin`, `ibin`, `hbin` matrix files USearch provides `load_matrix` and `save_matrix`.
|
|
478
|
+
Such files are standard in k-ANN tasks and represent a binary object with all the scalars, prepended by two 32-bit integers - the number of rows and columns in the matrix.
|
|
479
|
+
|
|
329
480
|
```py
|
|
330
481
|
from usearch.index import Index
|
|
331
482
|
from usearch.io import load_matrix, save_matrix
|
|
@@ -335,6 +486,28 @@ index = Index(ndim=vectors.shape[1])
|
|
|
335
486
|
index.add(labels, vectors)
|
|
336
487
|
```
|
|
337
488
|
|
|
489
|
+
One may often want to evaluate the quality of the constructed index before running in production.
|
|
490
|
+
The trivial way is to measure `recall@1` on the entries already present in the index.
|
|
491
|
+
|
|
492
|
+
```py
|
|
493
|
+
from usearch.eval import recall_members
|
|
494
|
+
|
|
495
|
+
assert recall_members(index, exact=True) == 1
|
|
496
|
+
print(recall_members(index, exact=False))
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
In case you have some ground-truth data for more than one entry, you compare search results against expected values:
|
|
500
|
+
|
|
501
|
+
```py
|
|
502
|
+
from usearch.eval import relevance, dcg, ndcg, random_vectors
|
|
503
|
+
|
|
504
|
+
vectors = random_vectors(index=index)
|
|
505
|
+
matches_approximate = index.search(vectors)
|
|
506
|
+
matches_exact = index.search(vectors, exact=True)
|
|
507
|
+
relevance_scores = relevance(matches_exact, matches_approximate)
|
|
508
|
+
print(dcg(relevance_scores), ndcg(relevance_scores))
|
|
509
|
+
```
|
|
510
|
+
|
|
338
511
|
### JavaScript
|
|
339
512
|
|
|
340
513
|
#### Installation
|
package/conanfile.py
CHANGED
|
@@ -4,7 +4,7 @@ from conans import ConanFile
|
|
|
4
4
|
class USearchConan(ConanFile):
|
|
5
5
|
|
|
6
6
|
name = 'USearch'
|
|
7
|
-
version = '0.
|
|
7
|
+
version = '0.19.0'
|
|
8
8
|
license = 'Apache 2.0'
|
|
9
9
|
url = 'https://github.com/unum-cloud/usearch'
|
|
10
10
|
description = 'Smaller & Faster Single-File Vector Search Engine from Unum'
|
package/cpp/bench.cpp
CHANGED
|
@@ -483,7 +483,7 @@ void run_punned(dataset_at& dataset, args_t const& args, index_config_t config,
|
|
|
483
483
|
|
|
484
484
|
index_at index = index_at::make(dataset.dimensions(), kind, config, accuracy);
|
|
485
485
|
index.reserve(limits);
|
|
486
|
-
std::printf("-- Hardware acceleration: %s\n", isa_name(index.
|
|
486
|
+
std::printf("-- Hardware acceleration: %s\n", isa_name(index.metric().isa_));
|
|
487
487
|
std::printf("Will benchmark in-memory\n");
|
|
488
488
|
|
|
489
489
|
single_shot(dataset, index, true);
|
|
@@ -535,7 +535,7 @@ void run_big_or_small(dataset_at& dataset, args_t const& args, index_config_t co
|
|
|
535
535
|
|
|
536
536
|
void report_alternative_setups() {
|
|
537
537
|
using set_member_t = std::uint32_t;
|
|
538
|
-
using sets_index_t = index_gt<jaccard_gt<set_member_t>, std::size_t, std::uint32_t
|
|
538
|
+
using sets_index_t = index_gt<jaccard_gt<set_member_t>, std::size_t, std::uint32_t>;
|
|
539
539
|
set_member_t set_a[] = {10, 12, 15};
|
|
540
540
|
set_member_t set_b[] = {11, 12, 15, 16};
|
|
541
541
|
sets_index_t index;
|
package/cpp/test.cpp
CHANGED
|
@@ -20,7 +20,8 @@ template <typename scalar_at, typename index_at> void test3d(index_at&& index) {
|
|
|
20
20
|
|
|
21
21
|
using scalar_t = scalar_at;
|
|
22
22
|
using view_t = span_gt<scalar_t const>;
|
|
23
|
-
using
|
|
23
|
+
using index_t = typename std::remove_reference<index_at>::type;
|
|
24
|
+
using distance_t = typename index_t::distance_t;
|
|
24
25
|
|
|
25
26
|
scalar_t vec[3] = {10, 20, 15};
|
|
26
27
|
scalar_t vec_a[3] = {15, 16, 17};
|
|
@@ -67,6 +68,7 @@ template <typename scalar_at, typename index_at> void test3d(index_at&& index) {
|
|
|
67
68
|
expect(std::abs(matched_distances[0]) < 0.01);
|
|
68
69
|
|
|
69
70
|
// Search again over mapped index
|
|
71
|
+
index_metadata("tmp.usearch");
|
|
70
72
|
index.view("tmp.usearch");
|
|
71
73
|
matched_count = index.search(view_t{&vec[0], 3ul}, 5).dump_to(matched_labels, matched_distances);
|
|
72
74
|
expect(matched_count == 3);
|
|
@@ -144,11 +146,11 @@ int main(int, char**) {
|
|
|
144
146
|
// static_assert(!std::is_same<index_gt<pearson_correlation_gt<>>::value_type, std::true_type>());
|
|
145
147
|
// static_assert(!std::is_same<index_gt<haversine_gt<>>::value_type, std::true_type>());
|
|
146
148
|
|
|
147
|
-
test3d<float>(index_gt<cos_gt<float>, point_id_t, std::uint32_t
|
|
148
|
-
test3d<float>(index_gt<l2sq_gt<float>, point_id_t, std::uint32_t
|
|
149
|
+
test3d<float>(index_gt<cos_gt<float>, point_id_t, std::uint32_t>{});
|
|
150
|
+
test3d<float>(index_gt<l2sq_gt<float>, point_id_t, std::uint32_t>{});
|
|
149
151
|
|
|
150
|
-
test3d<double>(index_gt<cos_gt<double>, point_id_t, std::uint32_t
|
|
151
|
-
test3d<double>(index_gt<l2sq_gt<double>, point_id_t, std::uint32_t
|
|
152
|
+
test3d<double>(index_gt<cos_gt<double>, point_id_t, std::uint32_t>{});
|
|
153
|
+
test3d<double>(index_gt<l2sq_gt<double>, point_id_t, std::uint32_t>{});
|
|
152
154
|
|
|
153
155
|
test3d<float>(punned_small_t::make(3, metric_kind_t::cos_k));
|
|
154
156
|
test3d<float>(punned_small_t::make(3, metric_kind_t::l2sq_k));
|
|
@@ -159,11 +161,11 @@ int main(int, char**) {
|
|
|
159
161
|
test3d_punned<float>(punned_small_t::make(3, metric_kind_t::cos_k));
|
|
160
162
|
test3d_punned<float>(punned_small_t::make(3, metric_kind_t::l2sq_k));
|
|
161
163
|
|
|
162
|
-
test_sets(index_gt<jaccard_gt<std::int32_t, float>, point_id_t, std::uint32_t
|
|
163
|
-
test_sets(index_gt<jaccard_gt<std::int64_t, float>, point_id_t, std::uint32_t
|
|
164
|
+
test_sets(index_gt<jaccard_gt<std::int32_t, float>, point_id_t, std::uint32_t>{});
|
|
165
|
+
test_sets(index_gt<jaccard_gt<std::int64_t, float>, point_id_t, std::uint32_t>{});
|
|
164
166
|
|
|
165
|
-
test_sets_moved<index_gt<jaccard_gt<std::int32_t, float>, point_id_t, std::uint32_t
|
|
166
|
-
test_sets_moved<index_gt<jaccard_gt<std::int64_t, float>, point_id_t, std::uint32_t
|
|
167
|
+
test_sets_moved<index_gt<jaccard_gt<std::int32_t, float>, point_id_t, std::uint32_t>>();
|
|
168
|
+
test_sets_moved<index_gt<jaccard_gt<std::int64_t, float>, point_id_t, std::uint32_t>>();
|
|
167
169
|
|
|
168
170
|
return 0;
|
|
169
171
|
}
|
package/docs/compilation.md
CHANGED
|
@@ -11,14 +11,16 @@ git submodule update --init --recursive
|
|
|
11
11
|
Linux:
|
|
12
12
|
|
|
13
13
|
```sh
|
|
14
|
+
sudo apt-get update
|
|
15
|
+
sudo apt-get install cmake g++-12 build-essential libjemalloc-dev
|
|
14
16
|
cmake -B ./build_release \
|
|
15
17
|
-DCMAKE_CXX_COMPILER="g++-12" \
|
|
16
18
|
-DCMAKE_BUILD_TYPE=Release \
|
|
17
|
-
-
|
|
19
|
+
-DUSEARCH_BUILD_TEST=1 \
|
|
18
20
|
-DUSEARCH_USE_OPENMP=1 \
|
|
19
21
|
-DUSEARCH_USE_SIMSIMD=1 \
|
|
22
|
+
-DUSEARCH_USE_JEMALLOC=1 \
|
|
20
23
|
-DUSEARCH_BUILD_BENCHMARK=1 \
|
|
21
|
-
-DUSEARCH_BUILD_TEST=1 \
|
|
22
24
|
&& \
|
|
23
25
|
make -C ./build_release -j
|
|
24
26
|
```
|