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 CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "usearch"
3
- version = "0.18.6"
3
+ version = "0.19.0"
4
4
  authors = ["Ashot Vardanian <1983160+ashvardanian@users.noreply.github.com>"]
5
5
  description = "Smaller & Faster Single-File Vector Search Engine from Unum"
6
6
  edition = "2021"
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
- | | FAISS, `f32` | USearch, `f32` | USearch, `f16` | USearch, `f8` |
73
- | :--------- | -----------: | -------------: | -------------: | ------------: |
74
- | Insertions | 76 K/s | 105 K/s | 115 K/s | **202 K/s** |
75
- | Queries | 118 K/s | 174 K/s | 173 K/s | **304 K/s** |
76
- | Recall @1 | 99% | 99.2% | 99.1% | **99.2%** |
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], dtype=np.float32)
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 python_dot(a, b):
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=python_dot.address)
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
- To use Numba JIT, install USearch with extras:
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 usearch[jit]
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.18.6'
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.isa()));
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, set_member_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 distance_t = typename index_at::distance_t;
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, float>{});
148
- test3d<float>(index_gt<l2sq_gt<float>, point_id_t, std::uint32_t, float>{});
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, double>{});
151
- test3d<double>(index_gt<l2sq_gt<double>, point_id_t, std::uint32_t, double>{});
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, std::int32_t>{});
163
- test_sets(index_gt<jaccard_gt<std::int64_t, float>, point_id_t, std::uint32_t, std::int64_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, std::int32_t>>();
166
- test_sets_moved<index_gt<jaccard_gt<std::int64_t, float>, point_id_t, std::uint32_t, std::int64_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
  }
@@ -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
- -DUSEARCH_USE_JEMALLOC=1 \
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
  ```