usearch 0.14.0 → 0.15.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.
Files changed (64) hide show
  1. package/.github/workflows/release.yml +56 -4
  2. package/.github/workflows/update_version.sh +6 -1
  3. package/.vscode/launch.json +21 -22
  4. package/.vscode/settings.json +7 -1
  5. package/CITATION.cff +4 -4
  6. package/CMakeLists.txt +1 -1
  7. package/Cargo.toml +1 -1
  8. package/Package.swift +0 -1
  9. package/README.md +100 -27
  10. package/binding.gyp +0 -1
  11. package/build.gradle +1 -1
  12. package/build.rs +3 -3
  13. package/c/lib.cpp +32 -9
  14. package/c/usearch.h +67 -36
  15. package/conanfile.py +1 -1
  16. package/{src → cpp}/bench.cpp +52 -36
  17. package/{src → cpp}/test.cpp +17 -7
  18. package/docs/benchmarks.md +33 -10
  19. package/docs/c/index.rst +11 -0
  20. package/docs/c/reference.rst +7 -0
  21. package/docs/compilation.md +24 -9
  22. package/docs/conf.dox +6 -6
  23. package/docs/conf.py +3 -1
  24. package/docs/cpp/index.rst +11 -0
  25. package/docs/cpp/reference.rst +7 -0
  26. package/docs/index.rst +10 -0
  27. package/docs/java/index.rst +11 -0
  28. package/docs/java/reference.rst +7 -0
  29. package/docs/javascript/index.rst +11 -0
  30. package/docs/javascript/reference.rst +9 -0
  31. package/docs/python/index.rst +11 -0
  32. package/docs/python/reference.rst +35 -0
  33. package/golang/lib.go +1 -1
  34. package/include/usearch/{usearch.hpp → index.hpp} +152 -148
  35. package/include/usearch/index_punned_dense.hpp +633 -0
  36. package/include/usearch/index_punned_helpers.hpp +528 -0
  37. package/java/cloud/unum/usearch/cloud_unum_usearch_Index.cpp +8 -7
  38. package/javascript/lib.cpp +26 -50
  39. package/javascript/usearch.js +117 -2
  40. package/objc/USearchObjective.mm +145 -49
  41. package/objc/include/USearchObjective.h +106 -30
  42. package/package.json +2 -2
  43. package/python/lib.cpp +134 -331
  44. package/python/scripts/bench.ipynb +170 -0
  45. package/python/scripts/bench.py +87 -175
  46. package/python/scripts/index_faiss.py +100 -0
  47. package/python/scripts/test.py +41 -37
  48. package/python/usearch/eval.py +353 -0
  49. package/python/usearch/index.py +117 -113
  50. package/python/usearch/io.py +7 -2
  51. package/python/usearch/join.py +100 -0
  52. package/python/usearch/numba.py +101 -0
  53. package/rust/lib.cpp +27 -14
  54. package/rust/lib.hpp +4 -10
  55. package/rust/lib.rs +50 -17
  56. package/setup.py +12 -2
  57. package/simsimd/include/simsimd/simsimd.h +32 -3
  58. package/swift/Index+Sugar.swift +55 -29
  59. package/swift/Test.swift +1 -1
  60. package/wolfram/lib.cpp +4 -4
  61. package/python/scripts/bench_params.py +0 -201
  62. package/python/usearch/synthetic.py +0 -31
  63. package/src/punned.hpp +0 -906
  64. /package/{src → cpp}/CMakeLists.txt +0 -0
@@ -3,9 +3,9 @@ import pytest
3
3
  import numpy as np
4
4
 
5
5
  from usearch.io import load_matrix, save_matrix
6
- from usearch.synthetic import recall_members
6
+ from usearch.eval import recall_members, random_vectors
7
7
 
8
- from usearch.index import Index, SetsIndex, MetricKind, Matches
8
+ from usearch.index import Index, SparseIndex, MetricKind, ScalarKind, Matches
9
9
  from usearch.index import (
10
10
  DEFAULT_CONNECTIVITY,
11
11
  DEFAULT_EXPANSION_ADD,
@@ -13,10 +13,14 @@ from usearch.index import (
13
13
  )
14
14
 
15
15
 
16
- dimensions = [13, 99, 100, 256]
17
- batch_sizes = [1, 2, 100]
18
- index_types = ['f32', 'f64', 'f16', 'f8']
19
- numpy_types = [np.float32, np.float64, np.float16, np.byte]
16
+ dimensions = [3, 97, 256]
17
+ batch_sizes = [1, 33]
18
+ index_types = [
19
+ ScalarKind.F32, ScalarKind.F64,
20
+ ScalarKind.F16, ScalarKind.F8,
21
+ ]
22
+ numpy_types = [np.float32, np.float64, np.float16]
23
+
20
24
  connectivity_options = [3, 13, 50, DEFAULT_CONNECTIVITY]
21
25
  jit_options = [False]
22
26
  continuous_metrics = [
@@ -24,9 +28,9 @@ continuous_metrics = [
24
28
  MetricKind.L2sq,
25
29
  ]
26
30
  hash_metrics = [
27
- MetricKind.BitwiseHamming,
28
- MetricKind.BitwiseTanimoto,
29
- MetricKind.BitwiseSorensen,
31
+ MetricKind.Hamming,
32
+ MetricKind.Tanimoto,
33
+ MetricKind.Sorensen,
30
34
  ]
31
35
 
32
36
 
@@ -77,9 +81,15 @@ def test_index(
77
81
 
78
82
  limit = 0.7 if numpy_type != np.byte else 70
79
83
  vector = np.random.uniform(0, limit, (index.ndim)).astype(numpy_type)
84
+
80
85
  index.add(42, vector)
81
- matches, distances, count = index.search(vector, 10)
82
86
 
87
+ assert 42 in index
88
+ assert 42 in index.labels
89
+ if numpy_type != np.byte:
90
+ assert np.allclose(index[42], vector, atol=0.1)
91
+
92
+ matches, distances, count = index.search(vector, 10)
83
93
  assert len(index) == 1
84
94
  assert len(matches) == count
85
95
  assert len(distances) == count
@@ -87,11 +97,6 @@ def test_index(
87
97
  assert matches[0] == 42
88
98
  assert distances[0] == pytest.approx(0, abs=1e-3)
89
99
 
90
- assert 42 in index
91
- assert 42 in index.labels
92
- if numpy_type != np.byte:
93
- assert np.allclose(index[42], vector, atol=0.1)
94
-
95
100
  index.save('tmp.usearch')
96
101
  index.clear()
97
102
  assert len(index) == 0
@@ -111,14 +116,15 @@ def test_index_batch(
111
116
 
112
117
  index = Index(ndim=ndim, metric=metric, dtype=index_type)
113
118
 
114
- limit = 0.7 if numpy_type != np.byte else 70
115
- vectors = np.random.uniform(
116
- 0, limit, (batch_size, index.ndim)).astype(numpy_type)
117
- labels = np.array(range(batch_size), dtype=np.longlong)
119
+ labels = np.arange(batch_size)
120
+ vectors = random_vectors(count=batch_size, ndim=ndim, dtype=numpy_type)
118
121
 
119
122
  index.add(labels, vectors)
120
- matches: Matches = index.search(vectors, 10)
123
+ assert len(index) == batch_size
124
+ assert np.allclose(index.get_vectors(
125
+ labels).astype(numpy_type), vectors, atol=0.1)
121
126
 
127
+ matches: Matches = index.search(vectors, 10)
122
128
  assert matches.labels.shape[0] == matches.distances.shape[0]
123
129
  assert matches.counts.shape[0] == batch_size
124
130
  assert np.all(np.sort(index.labels) == np.sort(labels))
@@ -128,7 +134,7 @@ def test_index_batch(
128
134
 
129
135
  @pytest.mark.parametrize('ndim', dimensions)
130
136
  @pytest.mark.parametrize('batch_size', batch_sizes)
131
- def test_index_udf(ndim: int, batch_size: int):
137
+ def test_index_numba(ndim: int, batch_size: int):
132
138
 
133
139
  try:
134
140
  from numba import cfunc, types, carray
@@ -137,37 +143,35 @@ def test_index_udf(ndim: int, batch_size: int):
137
143
 
138
144
  # Showcases how to use Numba to JIT-compile similarity measures for USearch.
139
145
  # https://numba.readthedocs.io/en/stable/reference/jit-compilation.html#c-callbacks
140
- signature = types.float32(
141
- types.CPointer(types.float32),
142
- types.CPointer(types.float32),
143
- types.uint64, types.uint64)
146
+ signature = types.float32(types.CPointer(
147
+ types.float32), types.CPointer(types.float32))
144
148
 
145
149
  @cfunc(signature)
146
- def python_dot(a, b, n, m):
147
- a_array = carray(a, n)
148
- b_array = carray(b, n)
150
+ def python_dot(a, b):
151
+ a_array = carray(a, ndim)
152
+ b_array = carray(b, ndim)
149
153
  c = 0.0
150
- for i in range(n):
154
+ for i in range(ndim):
151
155
  c += a_array[i] * b_array[i]
152
- return c
156
+ return 1 - c
153
157
 
154
158
  index = Index(ndim=ndim, metric=python_dot)
155
159
 
156
- vectors = np.random.uniform(
157
- 0, 0.3, (batch_size, index.ndim)).astype(np.float32)
158
- labels = np.array(range(batch_size), dtype=np.longlong)
160
+ labels = np.arange(batch_size)
161
+ vectors = random_vectors(count=batch_size, ndim=ndim)
159
162
 
160
163
  index.add(labels, vectors)
161
164
  matches, distances, count = index.search(vectors, 10)
162
-
163
165
  assert matches.shape[0] == distances.shape[0]
164
166
  assert count.shape[0] == batch_size
165
167
 
168
+ assert recall_members(index, exact=True) == 1
169
+
166
170
 
167
171
  @pytest.mark.parametrize('connectivity', connectivity_options)
168
172
  def test_sets_index(connectivity: int):
169
173
 
170
- index = SetsIndex(connectivity=connectivity)
174
+ index = SparseIndex(connectivity=connectivity)
171
175
  index.add(10, np.array([10, 12, 15], dtype=np.uint32))
172
176
  index.add(11, np.array([11, 12, 15, 16], dtype=np.uint32))
173
177
  results = index.search(np.array([12, 15], dtype=np.uint32), 10)
@@ -178,13 +182,13 @@ def test_sets_index(connectivity: int):
178
182
  @pytest.mark.parametrize('metric', hash_metrics)
179
183
  @pytest.mark.parametrize('connectivity', connectivity_options)
180
184
  @pytest.mark.parametrize('batch_size', batch_sizes)
181
- def test_hash_index(bits: int, metric: MetricKind, connectivity: int, batch_size: int):
185
+ def test_bitwise_index(bits: int, metric: MetricKind, connectivity: int, batch_size: int):
182
186
 
183
187
  index = Index(ndim=bits, metric=metric, connectivity=connectivity)
184
188
 
189
+ labels = np.arange(batch_size)
185
190
  bit_vectors = np.random.randint(2, size=(batch_size, bits))
186
191
  bit_vectors = np.packbits(bit_vectors, axis=1)
187
- labels = np.array(range(batch_size), dtype=np.longlong)
188
192
 
189
193
  index.add(labels, bit_vectors)
190
194
  index.search(bit_vectors, 10)
@@ -0,0 +1,353 @@
1
+ from __future__ import annotations
2
+ from time import time_ns
3
+ from typing import Tuple, Any, Callable, Union, Optional, List
4
+ from dataclasses import dataclass, asdict
5
+ from collections import defaultdict
6
+
7
+ import numpy as np
8
+
9
+ from usearch.io import load_matrix
10
+ from usearch.index import Index, Matches, MetricKind, MetricKindBitwise
11
+
12
+
13
+ def random_vectors(
14
+ count: int,
15
+ metric: MetricKind = MetricKind.IP,
16
+ dtype: np.generic = np.float32,
17
+ ndim: Optional[int] = None,
18
+ index: Optional[Index] = None,
19
+ ) -> np.ndarray:
20
+ """Produces a collection of random vectors normalized for the provided `metric`
21
+ and matching wanted `dtype`, which can both be inferred from an existing `index`.
22
+ """
23
+
24
+ # Infer default parameters from the `index`, if passed
25
+ if index is not None:
26
+ if not isinstance(index, Index):
27
+ raise ValueError('Unsupported `index` type')
28
+
29
+ ndim = index.ndim
30
+ dtype = index.numpy_dtype
31
+ metric = index.metric
32
+
33
+ # Produce data
34
+ if metric in MetricKindBitwise:
35
+ bit_vectors = np.random.randint(2, size=(count, ndim))
36
+ bit_vectors = np.packbits(bit_vectors, axis=1)
37
+ return bit_vectors
38
+
39
+ else:
40
+
41
+ x = np.random.rand(count, ndim)
42
+ if dtype == np.int8:
43
+ x = (x * 100).astype(np.int8)
44
+ else:
45
+ x = x.astype(dtype)
46
+ if metric == MetricKind.IP:
47
+ return x / np.linalg.norm(x, axis=1, keepdims=True)
48
+ return x
49
+
50
+
51
+ def recall_members(index: Index, *args, **kwargs) -> float:
52
+ """Simplest benchmark for a quality of search, which queries every
53
+ existing member of the index, to make sure approximate search finds
54
+ the point itself.
55
+
56
+ :param index: Non-empty pre-constructed index
57
+ :type index: Index
58
+ :return: Value from 0 to 1, for the share of found self-references
59
+ :rtype: float
60
+ """
61
+ if len(index) == 0:
62
+ return 0
63
+
64
+ matches: Matches = index.search(index.vectors, 1, *args, **kwargs)
65
+ return matches.recall_first(index.labels)
66
+
67
+
68
+ def measure_seconds(f: Callable) -> Tuple[float, Any]:
69
+ """Simple function profiling decorator.
70
+
71
+ :param f: Function to be profiled
72
+ :type f: Callable
73
+ :return: Time elapsed in seconds and the result of the execution
74
+ :rtype: Tuple[float, Any]
75
+ """
76
+ a = time_ns()
77
+ result = f()
78
+ b = time_ns()
79
+ c = b - a
80
+ secs = c / (10 ** 9)
81
+ return secs, result
82
+
83
+
84
+ @dataclass
85
+ class Dataset:
86
+
87
+ labels: np.ndarray
88
+ vectors: np.ndarray
89
+ queries: np.ndarray
90
+ neighbors: np.ndarray
91
+
92
+ def crop_neighbors(self, k: int):
93
+ self.neighbors = self.neighbors[:, k]
94
+
95
+ @staticmethod
96
+ def build(
97
+ vectors: Optional[str] = None,
98
+ queries: Optional[str] = None,
99
+ neighbors: Optional[str] = None,
100
+ count: Optional[int] = None,
101
+ ndim: Optional[int] = None,
102
+ k: Optional[int] = None,
103
+ ):
104
+ """Either loads an existing dataset from disk, or generates one on the fly.
105
+
106
+ :param vectors: _description_, defaults to None
107
+ :type vectors: Optional[str], optional
108
+ :param queries: _description_, defaults to None
109
+ :type queries: Optional[str], optional
110
+ :param neighbors: _description_, defaults to None
111
+ :type neighbors: Optional[str], optional
112
+ :param count: _description_, defaults to None
113
+ :type count: Optional[int], optional
114
+ :param ndim: _description_, defaults to None
115
+ :type ndim: Optional[int], optional
116
+ :param k: _description_, defaults to None
117
+ :type k: Optional[int], optional
118
+ """
119
+
120
+ d = Dataset(None, None, None, None)
121
+
122
+ if vectors is not None:
123
+ assert ndim is None
124
+
125
+ d.vectors = load_matrix(vectors)
126
+ ndim = d.vectors.shape[1]
127
+ count = min(
128
+ d.vectors.shape[0], count) if count is not None else d.vectors.shape[0]
129
+ d.vectors = d.vectors[:count, :]
130
+ d.labels = np.arange(count, dtype=np.longlong)
131
+
132
+ if queries is not None:
133
+ d.queries = load_matrix(queries)
134
+ else:
135
+ d.queries = d.vectors
136
+
137
+ if neighbors is not None:
138
+ d.neighbors = load_matrix(neighbors)
139
+ if k is not None:
140
+ d.neighbors = d.neighbors[:, :k]
141
+ else:
142
+ assert k is None, 'Cant ovveride `k`, will retrieve one neighbor'
143
+ d.neighbors = np.reshape(d.labels, (count, 1))
144
+
145
+ else:
146
+
147
+ assert ndim is not None
148
+ assert count is not None
149
+ assert k is None, 'Cant ovveride `k`, will retrieve one neighbor'
150
+
151
+ d.vectors = random_vectors(count=count, ndim=ndim)
152
+ d.queries = d.vectors
153
+ d.labels = np.arange(count, dtype=np.longlong)
154
+ d.neighbors = np.reshape(d.labels, (count, 1))
155
+
156
+ return d
157
+
158
+
159
+ @dataclass
160
+ class TaskResult:
161
+
162
+ add_operations: Optional[int] = None
163
+ add_per_second: Optional[float] = None
164
+
165
+ search_operations: Optional[int] = None
166
+ search_per_second: Optional[float] = None
167
+ recall_at_one: Optional[float] = None
168
+
169
+ def __repr__(self) -> str:
170
+ parts = []
171
+ if self.add_per_second:
172
+ parts.append(f'{self.add_per_second:.2f} add/s')
173
+ if self.search_per_second:
174
+ parts.append(f'{self.search_per_second:.2f} search/s')
175
+ if self.recall_at_one:
176
+ parts.append(f'{self.recall_at_one * 100:.2f}% recall@1')
177
+ return ', '.join(parts)
178
+
179
+ @property
180
+ def add_seconds(self) -> float:
181
+ return self.add_operations / self.add_per_second
182
+
183
+ @property
184
+ def search_seconds(self) -> float:
185
+ return self.search_operations / self.search_per_second
186
+
187
+ def __add__(self, other: TaskResult, /):
188
+ result = TaskResult()
189
+ if self.add_operations and other.add_operations:
190
+ result.add_operations = self.add_operations + other.add_operations
191
+ result.add_per_second = result.add_operations / \
192
+ (self.add_seconds + other.add_seconds)
193
+ else:
194
+ base = self if self.add_operations else other
195
+ result.add_operations = base.add_operations
196
+ result.add_per_second = base.add_per_second
197
+
198
+ if self.search_operations and other.search_operations:
199
+ result.search_operations = self.search_operations + other.search_operations
200
+ result.recall_at_one = (
201
+ self.recall_at_one * self.search_operations + other.recall_at_one *
202
+ other.search_operations) / (self.search_operations + other.search_operations)
203
+ result.search_per_second = result.search_operations / \
204
+ (self.search_seconds + other.search_seconds)
205
+ else:
206
+ base = self if self.search_operations else other
207
+ result.search_operations = base.search_operations
208
+ result.search_per_second = base.search_per_second
209
+ result.recall_at_one = base.recall_at_one
210
+
211
+ return result
212
+
213
+
214
+ @dataclass
215
+ class AddTask:
216
+
217
+ labels: np.ndarray
218
+ vectors: np.ndarray
219
+
220
+ def __call__(self, index: Index) -> TaskResult:
221
+
222
+ batch_size: int = self.vectors.shape[0]
223
+ old_size: int = len(index)
224
+ dt, _ = measure_seconds(
225
+ lambda: index.add(self.labels, self.vectors))
226
+
227
+ assert len(index) == old_size + batch_size
228
+ return TaskResult(
229
+ add_operations=batch_size,
230
+ add_per_second=batch_size/dt,
231
+ )
232
+
233
+ @property
234
+ def ndim(self):
235
+ return self.vectors.shape[1]
236
+
237
+ @property
238
+ def count(self):
239
+ return self.vectors.shape[0]
240
+
241
+ def inplace_shuffle(self):
242
+ """Rorders the `vectors` and `labels`. Often used for robustness benchmarks."""
243
+
244
+ new_order = np.arange(self.count)
245
+ np.random.shuffle(new_order)
246
+ self.labels = self.labels[new_order]
247
+ self.vectors = self.vectors[new_order, :]
248
+
249
+ def slices(self, batch_size: int) -> List[AddTask]:
250
+ """Splits this dataset into smaller chunks."""
251
+
252
+ return [
253
+ AddTask(
254
+ labels=self.labels[start_row:start_row+batch_size],
255
+ vectors=self.vectors[start_row:start_row+batch_size, :],
256
+ ) for start_row in range(0, self.count, batch_size)]
257
+
258
+ def clusters(self, number_of_clusters: int) -> List[AddTask]:
259
+ """Splits this dataset into smaller chunks."""
260
+
261
+ from sklearn.cluster import KMeans
262
+ clustering = KMeans(
263
+ n_clusters=number_of_clusters,
264
+ random_state=0, n_init='auto',
265
+ ).fit(self.vectors)
266
+
267
+ partitioning = defaultdict(list)
268
+ for row, cluster in enumerate(clustering.labels_):
269
+ partitioning[cluster].append(row)
270
+
271
+ return [
272
+ AddTask(
273
+ labels=self.labels[rows],
274
+ vectors=self.vectors[rows, :],
275
+ ) for rows in partitioning.values()]
276
+
277
+
278
+ @dataclass
279
+ class SearchTask:
280
+
281
+ queries: np.ndarray
282
+ neighbors: np.ndarray
283
+
284
+ def __call__(self, index: Index) -> TaskResult:
285
+
286
+ dt, results = measure_seconds(
287
+ lambda: index.search(self.queries, self.neighbors.shape[1]))
288
+
289
+ return TaskResult(
290
+ search_per_second=self.queries.shape[0]/dt,
291
+ recall_at_one=results.recall_first(self.neighbors[:, 0].flatten()),
292
+ )
293
+
294
+ def slices(self, batch_size: int) -> List[SearchTask]:
295
+ """Splits this dataset into smaller chunks."""
296
+
297
+ return [
298
+ SearchTask(
299
+ queries=self.queries[start_row:start_row+batch_size, :],
300
+ neighbors=self.neighbors[start_row:start_row+batch_size, :],
301
+ ) for start_row in range(0, self.queries.shape[0], batch_size)]
302
+
303
+
304
+ @dataclass
305
+ class Evaluation:
306
+
307
+ tasks: List[Union[AddTask, SearchTask]]
308
+ count: int
309
+ ndim: int
310
+
311
+ @staticmethod
312
+ def for_dataset(dataset: Dataset, batch_size: int = 0, clusters: int = 1) -> Evaluation:
313
+ tasks = []
314
+ add = AddTask(
315
+ vectors=dataset.vectors,
316
+ labels=dataset.labels)
317
+ search = SearchTask(
318
+ queries=dataset.queries,
319
+ neighbors=dataset.neighbors)
320
+
321
+ if batch_size:
322
+ tasks.extend(add.slices(batch_size))
323
+ tasks.extend(search.slices(batch_size))
324
+ elif clusters != 1:
325
+ tasks.extend(add.clusters(clusters))
326
+ print(tasks)
327
+ tasks.append(search)
328
+ else:
329
+ tasks.append(add)
330
+ tasks.append(search)
331
+
332
+ return Evaluation(
333
+ tasks=tasks,
334
+ count=add.count,
335
+ ndim=add.ndim,
336
+ )
337
+
338
+ def __call__(self, index: Index, post_clean: bool = True) -> dict:
339
+
340
+ task_result = TaskResult()
341
+
342
+ try:
343
+ for task in self.tasks:
344
+ task_result = task_result + task(index)
345
+ except KeyboardInterrupt:
346
+ pass
347
+
348
+ if post_clean:
349
+ index.clear()
350
+ return {
351
+ **index.specs,
352
+ **asdict(task_result),
353
+ }