usearch 2.15.3 → 2.16.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.
Files changed (48) hide show
  1. package/README.md +1 -1
  2. package/cpp/test.cpp +16 -0
  3. package/include/usearch/index.hpp +21 -5
  4. package/include/usearch/index_dense.hpp +6 -1
  5. package/include/usearch/index_plugins.hpp +345 -8
  6. package/javascript/lib.cpp +17 -5
  7. package/javascript/usearch.test.js +190 -21
  8. package/package.json +1 -1
  9. package/prebuilds/darwin-arm64+x64/usearch.node +0 -0
  10. package/prebuilds/linux-arm64/usearch.node +0 -0
  11. package/prebuilds/linux-x64/usearch.node +0 -0
  12. package/simsimd/.git-blame-ignore-revs +2 -0
  13. package/simsimd/CONTRIBUTING.md +71 -23
  14. package/simsimd/Cargo.lock +1 -1
  15. package/simsimd/Cargo.toml +4 -4
  16. package/simsimd/MANIFEST.in +2 -1
  17. package/simsimd/Package.swift +15 -3
  18. package/simsimd/README.md +104 -35
  19. package/simsimd/binding.gyp +6 -2
  20. package/simsimd/build.rs +2 -0
  21. package/simsimd/c/lib.c +180 -99
  22. package/simsimd/include/simsimd/binary.h +311 -102
  23. package/simsimd/include/simsimd/curved.h +134 -135
  24. package/simsimd/include/simsimd/dot.h +448 -274
  25. package/simsimd/include/simsimd/elementwise.h +1437 -0
  26. package/simsimd/include/simsimd/geospatial.h +2 -2
  27. package/simsimd/include/simsimd/mesh.h +2 -2
  28. package/simsimd/include/simsimd/probability.h +88 -88
  29. package/simsimd/include/simsimd/simsimd.h +1157 -772
  30. package/simsimd/include/simsimd/sparse.h +768 -147
  31. package/simsimd/include/simsimd/spatial.h +955 -376
  32. package/simsimd/include/simsimd/types.h +145 -71
  33. package/simsimd/javascript/lib.c +1 -2
  34. package/simsimd/package.json +4 -4
  35. package/simsimd/rust/lib.rs +14 -1
  36. package/simsimd/scripts/bench.cxx +1070 -0
  37. package/simsimd/scripts/bench.ipynb +187 -0
  38. package/simsimd/{javascript → scripts}/bench.js +1 -1
  39. package/simsimd/{rust/benches/sqeuclidean.rs → scripts/bench_sqeuclidean.rs} +6 -6
  40. package/simsimd/scripts/bench_vectors.py +700 -0
  41. package/simsimd/scripts/bench_vectors_live.py +213 -0
  42. package/simsimd/scripts/test.c +155 -0
  43. package/simsimd/{javascript → scripts}/test.mjs +2 -2
  44. package/simsimd/scripts/test.py +1410 -0
  45. package/simsimd/swift/{source/SimSIMD.swift → SimSIMD.swift} +6 -2
  46. package/simsimd/swift/{tests/SimSIMDTests.swift → Test.swift} +1 -1
  47. /package/simsimd/{rust/benches/cosine.rs → scripts/bench_cosine.rs} +0 -0
  48. /package/simsimd/{rust/benches/native.rs → scripts/bench_native.rs} +0 -0
@@ -0,0 +1,700 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Module: bench.py
5
+
6
+ This script benchmarks the performance of SimSIMD against other libraries,
7
+ such as NumPy, SciPy, scikit-learn, PyTorch, TensorFlow, and JAX.
8
+ It can operate in 2 modes:
9
+
10
+ 1. Batch mode
11
+ 2. All-Pairs mode.
12
+
13
+ It also provides necessary primitives for performance visualizations and
14
+ other benchmarking scripts, like `bench_vectors_live.py`.
15
+ """
16
+ import os
17
+ import time
18
+ import argparse
19
+ from typing import List, Generator, Union
20
+ from dataclasses import dataclass
21
+
22
+
23
+ #! Before all else, ensure that we use only one thread for each library
24
+ os.environ["OMP_NUM_THREADS"] = "1" # OpenMP
25
+ os.environ["MKL_NUM_THREADS"] = "1" # MKL
26
+ os.environ["NUMEXPR_NUM_THREADS"] = "1" # NumExpr
27
+ os.environ["VECLIB_MAXIMUM_THREADS"] = "1" # Accelerate
28
+ os.environ["OPENBLAS_NUM_THREADS"] = "1" # OpenBLAS
29
+
30
+ # NumPy and SimSIMD are obligatory for benchmarking
31
+ import numpy as np
32
+ import simsimd as simd
33
+ import tabulate
34
+
35
+ # Set to ignore all floating-point errors
36
+ np.seterr(all="ignore")
37
+
38
+
39
+ metric_families = [
40
+ "dot", # Dot product
41
+ "spatial", # Euclidean and Cosine distance
42
+ "binary", # Hamming and Jaccard distance for binary vectors
43
+ "probability", # Jensen-Shannon and Kullback-Leibler divergences for probability distributions
44
+ "sparse", # Intersection of two sparse integer sets, with float/int weights
45
+ ]
46
+ dtype_names = [
47
+ "bits", #! Not supported by SciPy
48
+ "int8", #! Presented as supported, but overflows most of the time
49
+ "uint16",
50
+ "uint32",
51
+ "float16",
52
+ "float32",
53
+ "float64",
54
+ "bfloat16", #! Not supported by NumPy
55
+ "complex32", #! Not supported by NumPy
56
+ "complex64",
57
+ "complex128",
58
+ ]
59
+
60
+
61
+ @dataclass
62
+ class Kernel:
63
+ """Data class to store information about a numeric kernel."""
64
+
65
+ name: str
66
+ dtype: str
67
+ baseline_one_to_one_func: callable
68
+ baseline_many_to_many_func: callable
69
+ baseline_all_pairs_func: callable
70
+ simsimd_func: callable
71
+ simsimd_all_pairs_func: callable
72
+ tensor_type: callable = np.array
73
+
74
+
75
+ def yield_kernels(
76
+ metric_families: List[str],
77
+ dtype_names: List[str],
78
+ include_scipy: bool = False,
79
+ include_scikit: bool = False,
80
+ include_torch: bool = False,
81
+ include_tf: bool = False,
82
+ include_jax: bool = False,
83
+ ) -> Generator[Kernel, None, None]:
84
+ """Yield a list of kernels to latency."""
85
+
86
+ if include_scipy:
87
+ import scipy as sp
88
+ import scipy.spatial.distance as spd
89
+ import scipy.special as scs
90
+
91
+ if include_scikit:
92
+ import sklearn as sk
93
+ import sklearn.metrics.pairwise as skp
94
+
95
+ if include_torch:
96
+ import torch
97
+ if include_tf:
98
+ # Disable TensorFlow warning messages
99
+ os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3" # This hides INFO and WARNING messages
100
+
101
+ import tensorflow as tf
102
+
103
+ # This will show only ERROR messages, not WARNING messages.
104
+ # Additionally, to filter out oneDNN related warnings, you might need to:
105
+ tf.get_logger().setLevel("FATAL")
106
+
107
+ if include_jax:
108
+ import jax
109
+ import jax.numpy as jnp
110
+
111
+ # Define a few helper functions to wrap non-vectorized kernels
112
+ def wrap_rows_batch_calls(baseline_one_to_one_func):
113
+ """Wrap a function to apply it row-wise to rows of two matrices.
114
+ It's needed as SciPy functions don't support batch processing out of the box."""
115
+
116
+ def wrapped(A, B):
117
+ for i in range(A.shape[0]):
118
+ baseline_one_to_one_func(A[i], B[i])
119
+
120
+ return wrapped
121
+
122
+ def wrap_rows_all_pairs_calls(baseline_one_to_one_func):
123
+ """Wrap a function to apply it row-wise to all possible pairs of rows of two matrices.
124
+ It's needed as NumPy `vdot`-like functions don't support batch processing out of the box."""
125
+
126
+ def wrapped(A, B):
127
+ for i in range(A.shape[0]):
128
+ for j in range(B.shape[0]):
129
+ baseline_one_to_one_func(A[i], B[j])
130
+
131
+ return wrapped
132
+
133
+ def raise_(ex):
134
+ """Utility function to allow raising exceptions in lambda functions."""
135
+ raise ex
136
+
137
+ def for_dtypes(
138
+ name: str,
139
+ dtypes: List[str],
140
+ baseline_one_to_one_func: callable,
141
+ baseline_many_to_many_func: callable,
142
+ baseline_all_pairs_func: callable,
143
+ simsimd_func: callable,
144
+ simsimd_all_pairs_func: callable,
145
+ tensor_type: callable = np.array,
146
+ ) -> list:
147
+ """Filter out unsupported data types."""
148
+ return [
149
+ Kernel(
150
+ name=name,
151
+ baseline_one_to_one_func=baseline_one_to_one_func,
152
+ baseline_many_to_many_func=baseline_many_to_many_func,
153
+ baseline_all_pairs_func=baseline_all_pairs_func,
154
+ simsimd_func=simsimd_func,
155
+ simsimd_all_pairs_func=simsimd_all_pairs_func,
156
+ tensor_type=tensor_type,
157
+ dtype=dtype,
158
+ )
159
+ for dtype in dtypes
160
+ if dtype in dtype_names
161
+ ]
162
+
163
+ if "dot" in metric_families:
164
+ yield from for_dtypes(
165
+ "numpy.dot",
166
+ ["float64", "float32", "float16", "int8", "complex64", "complex128"],
167
+ np.dot,
168
+ lambda A, B: np.sum(A * B, axis=1),
169
+ lambda A, B: np.dot(A, B.T),
170
+ simd.dot,
171
+ lambda A, B: simd.cdist(A, B, metric="dot"),
172
+ )
173
+ yield from for_dtypes(
174
+ "numpy.dot",
175
+ ["complex32"],
176
+ lambda A, B: raise_(NotImplementedError("Not implemented for complex32")),
177
+ lambda A, B: raise_(NotImplementedError("Not implemented for complex32")),
178
+ lambda A, B: raise_(NotImplementedError("Not implemented for complex32")),
179
+ lambda A, B: simd.dot(A, B, "complex32"),
180
+ lambda A, B: simd.cdist(A, B, "complex32", metric="dot"),
181
+ )
182
+ yield from for_dtypes(
183
+ "numpy.dot",
184
+ ["bfloat16"],
185
+ lambda A, B: raise_(NotImplementedError("Not implemented for bfloat16")),
186
+ lambda A, B: raise_(NotImplementedError("Not implemented for bfloat16")),
187
+ lambda A, B: raise_(NotImplementedError("Not implemented for bfloat16")),
188
+ lambda A, B: simd.dot(A, B, "bfloat16"),
189
+ lambda A, B: simd.cdist(A, B, "bfloat16", metric="dot"),
190
+ )
191
+ yield from for_dtypes(
192
+ "numpy.vdot",
193
+ ["complex64", "complex128"],
194
+ np.vdot,
195
+ wrap_rows_batch_calls(np.vdot),
196
+ wrap_rows_all_pairs_calls(np.vdot),
197
+ simd.vdot,
198
+ lambda A, B: simd.cdist(A, B, metric="vdot"),
199
+ )
200
+ if "spatial" in metric_families and include_scipy:
201
+ yield from for_dtypes(
202
+ "scipy.cosine",
203
+ ["float64", "float32", "float16", "int8"],
204
+ spd.cosine,
205
+ wrap_rows_batch_calls(spd.cosine),
206
+ lambda A, B: spd.cdist(A, B, "cosine"),
207
+ simd.cosine,
208
+ lambda A, B: simd.cdist(A, B, metric="cosine"),
209
+ )
210
+ yield from for_dtypes(
211
+ "scipy.cosine",
212
+ ["bfloat16"],
213
+ lambda A, B: raise_(NotImplementedError(f"Not implemented for bfloat16")),
214
+ lambda A, B: raise_(NotImplementedError(f"Not implemented for bfloat16")),
215
+ lambda A, B: raise_(NotImplementedError(f"Not implemented for bfloat16")),
216
+ lambda A, B: simd.cosine(A, B, "bfloat16"),
217
+ lambda A, B: simd.cdist(A, B, "bfloat16", metric="cosine"),
218
+ )
219
+ yield from for_dtypes(
220
+ "scipy.sqeuclidean",
221
+ ["float64", "float32", "float16", "int8"],
222
+ spd.sqeuclidean,
223
+ wrap_rows_batch_calls(spd.sqeuclidean),
224
+ lambda A, B: spd.cdist(A, B, "sqeuclidean"),
225
+ simd.sqeuclidean,
226
+ lambda A, B: simd.cdist(A, B, metric="sqeuclidean"),
227
+ )
228
+
229
+ if "probability" in metric_families and include_scipy:
230
+ yield from for_dtypes(
231
+ "scipy.jensenshannon",
232
+ ["float64", "float32", "float16"],
233
+ spd.jensenshannon,
234
+ wrap_rows_batch_calls(spd.jensenshannon),
235
+ lambda A, B: spd.cdist(A, B, "jensenshannon"),
236
+ simd.jensenshannon,
237
+ lambda A, B: simd.cdist(A, B, metric="jensenshannon"),
238
+ )
239
+ yield from for_dtypes(
240
+ "scipy.kl_div",
241
+ ["float64", "float32", "float16"],
242
+ scs.kl_div,
243
+ wrap_rows_batch_calls(scs.kl_div),
244
+ wrap_rows_all_pairs_calls(scs.kl_div),
245
+ simd.kullbackleibler,
246
+ lambda A, B: simd.cdist(A, B, metric="kullbackleibler"),
247
+ )
248
+ if "binary" in metric_families and include_scipy:
249
+ yield from for_dtypes(
250
+ "scipy.hamming",
251
+ ["bits"],
252
+ spd.hamming,
253
+ wrap_rows_batch_calls(spd.hamming),
254
+ lambda A, B: spd.cdist(A, B, "hamming"),
255
+ lambda A, B: simd.hamming(A, B, "bits"),
256
+ lambda A, B: simd.cdist(A, B, "bits", metric="hamming"),
257
+ )
258
+ yield from for_dtypes(
259
+ "scipy.jaccard",
260
+ ["bits"],
261
+ spd.jaccard,
262
+ wrap_rows_batch_calls(spd.jaccard),
263
+ lambda A, B: spd.cdist(A, B, "jaccard"),
264
+ lambda A, B: simd.jaccard(A, B, "bits"),
265
+ lambda A, B: simd.cdist(A, B, "bits", metric="jaccard"),
266
+ )
267
+ if "spatial" in metric_families and include_scikit:
268
+ yield from for_dtypes(
269
+ "sklearn.cosine_similarity",
270
+ ["float64", "float32", "float16", "int8"],
271
+ lambda A, B: skp.cosine_similarity(A.reshape(1, len(A)), B.reshape(1, len(B))),
272
+ lambda A, B: raise_(NotImplementedError("Not implemented for many-to-many")),
273
+ skp.paired_cosine_distances,
274
+ simd.cosine,
275
+ lambda A, B: simd.cdist(A, B, metric="cosine"),
276
+ )
277
+ yield from for_dtypes(
278
+ "sklearn.euclidean_distances",
279
+ ["float64", "float32", "float16", "int8"],
280
+ lambda A, B: skp.euclidean_distances(A.reshape(1, len(A)), B.reshape(1, len(B))),
281
+ lambda A, B: raise_(NotImplementedError("Not implemented for many-to-many")),
282
+ skp.paired_euclidean_distances,
283
+ simd.sqeuclidean,
284
+ lambda A, B: simd.cdist(A, B, metric="sqeuclidean"),
285
+ )
286
+ if "dot" in metric_families and include_tf:
287
+ yield from for_dtypes(
288
+ "tensorflow.tensordot",
289
+ ["float64", "float32", "float16", "int8"],
290
+ lambda A, B: tf.tensordot(A, B, axes=1).numpy(),
291
+ lambda A, B: tf.reduce_sum(tf.multiply(A, B), axis=1).numpy(),
292
+ lambda A, B: tf.tensordot(A, B.T, axes=1).numpy(),
293
+ simd.dot,
294
+ lambda A, B: simd.cdist(A, B, metric="dot"),
295
+ tf.convert_to_tensor,
296
+ )
297
+ if "dot" in metric_families and include_jax:
298
+ yield from for_dtypes(
299
+ "jax.numpy.dot",
300
+ ["float64", "float32", "float16", "int8"],
301
+ lambda A, B: jnp.dot(A, B).block_until_ready(),
302
+ lambda A, B: jnp.einsum("ij,ij->i", A, B).block_until_ready(),
303
+ lambda A, B: jnp.dot(A, B.T).block_until_ready(),
304
+ simd.dot,
305
+ lambda A, B: simd.cdist(A, B, metric="dot"),
306
+ jnp.array,
307
+ )
308
+ if "dot" in metric_families and include_torch:
309
+ yield from for_dtypes(
310
+ "torch.dot",
311
+ ["float64", "float32", "float16", "int8"],
312
+ lambda A, B: torch.dot(A, B).item(),
313
+ lambda A, B: torch.bmm(A.unsqueeze(1), B.unsqueeze(2)).squeeze(),
314
+ lambda A, B: torch.dot(A, B.T).item(),
315
+ simd.dot,
316
+ lambda A, B: simd.cdist(A, B, metric="dot"),
317
+ torch.tensor,
318
+ )
319
+
320
+
321
+ @dataclass
322
+ class Result:
323
+ dtype: str
324
+ name: str
325
+ baseline_seconds: Union[float, Exception]
326
+ simsimd_seconds: Union[float, Exception]
327
+ bytes_per_vector: int
328
+ distance_calculations: int
329
+
330
+
331
+ def random_matrix(count: int, ndim: int, dtype: str) -> np.ndarray:
332
+ if dtype == "complex128":
333
+ return (
334
+ np.random.randn(count, ndim // 2).astype(np.float64)
335
+ + 1j * np.random.randn(count, ndim // 2).astype(np.float64)
336
+ ).view(np.complex128)
337
+ if dtype == "complex64":
338
+ return (
339
+ np.random.randn(count, ndim // 2).astype(np.float32)
340
+ + 1j * np.random.randn(count, ndim // 2).astype(np.float32)
341
+ ).view(np.complex64)
342
+ if dtype == "complex32":
343
+ return np.random.randn(count, ndim).astype(np.float16)
344
+ if dtype == "float64":
345
+ return np.random.randn(count, ndim).astype(np.float64)
346
+ if dtype == "float32":
347
+ return np.random.randn(count, ndim).astype(np.float32)
348
+ if dtype == "float16":
349
+ return np.random.randn(count, ndim).astype(np.float16)
350
+ if dtype == "bfloat16":
351
+ return np.random.randint(0, high=256, size=(count, ndim), dtype=np.int16)
352
+ if dtype == "int8":
353
+ return np.random.randint(-100, high=100, size=(count, ndim), dtype=np.int8)
354
+ if dtype == "bits":
355
+ return np.packbits(np.random.randint(0, high=2, size=(count, ndim), dtype=np.uint8), axis=0)
356
+
357
+
358
+ def latency(func, A, B, iterations: int = 1, warmup: int = 0) -> float:
359
+ """Time the amount of time it takes to run a function and return the average time per run in seconds."""
360
+ while warmup > 0:
361
+ func(A, B)
362
+ warmup -= 1
363
+ start_time = time.time_ns()
364
+ while iterations > 0:
365
+ func(A, B)
366
+ iterations -= 1
367
+ end_time = time.time_ns()
368
+ return (end_time - start_time) / 1e9
369
+
370
+
371
+ def yield_batch_results(
372
+ count_vectors_per_matrix: int,
373
+ ndim: int,
374
+ kernels: List[Kernel],
375
+ warmup: int = 0,
376
+ ) -> Generator[Result, None, None]:
377
+ # For each of the present data types, we may want to pre-generate several random matrices
378
+ count_matrices_per_dtype = 16
379
+ count_repetitions_per_matrix = 3 # This helps dampen the effect of time-measurement itself
380
+ matrices_per_dtype = {}
381
+ for kernel in kernels:
382
+ if kernel.dtype in matrices_per_dtype:
383
+ continue
384
+ matrices = [
385
+ random_matrix(count_vectors_per_matrix, ndim, kernel.dtype) for _ in range(count_matrices_per_dtype)
386
+ ]
387
+ if count_vectors_per_matrix == 1:
388
+ matrices = [m.flatten() for m in matrices]
389
+ matrices_per_dtype[kernel.dtype] = matrices
390
+
391
+ # For each kernel, repeat benchmarks for each data type
392
+ for kernel in kernels:
393
+ matrices_numpy = matrices_per_dtype[kernel.dtype]
394
+ matrices_converted = [kernel.tensor_type(m) for m in matrices_numpy]
395
+ baseline_one_to_one_func = (
396
+ kernel.baseline_one_to_one_func if count_vectors_per_matrix == 1 else kernel.baseline_many_to_many_func
397
+ )
398
+ simsimd_func = kernel.simsimd_func
399
+ result = Result(
400
+ kernel.dtype,
401
+ kernel.name,
402
+ baseline_seconds=0,
403
+ simsimd_seconds=0,
404
+ bytes_per_vector=matrices_numpy[0].nbytes // count_vectors_per_matrix,
405
+ distance_calculations=count_vectors_per_matrix * count_matrices_per_dtype * count_repetitions_per_matrix,
406
+ )
407
+
408
+ # Try obtaining the baseline measurements
409
+ try:
410
+ for i in range(1, count_matrices_per_dtype):
411
+ result.baseline_seconds += latency(
412
+ baseline_one_to_one_func,
413
+ matrices_converted[i - 1],
414
+ matrices_converted[i],
415
+ count_repetitions_per_matrix,
416
+ warmup,
417
+ )
418
+ except NotImplementedError as e:
419
+ result.baseline_seconds = e
420
+ except ValueError as e:
421
+ result.baseline_seconds = e #! This happens often during overflows
422
+ except RuntimeError as e:
423
+ result.baseline_seconds = e #! This happens often during overflows
424
+ except Exception as e:
425
+ # This is an unexpected exception... once you face it, please report it
426
+ raise RuntimeError(str(e) + " for %s(%s)" % (kernel.name, str(kernel.dtype))) from e
427
+
428
+ # Try obtaining the SimSIMD measurements
429
+ try:
430
+ for i in range(1, count_matrices_per_dtype):
431
+ result.simsimd_seconds += latency(
432
+ simsimd_func,
433
+ matrices_numpy[i - 1],
434
+ matrices_numpy[i],
435
+ count_repetitions_per_matrix,
436
+ warmup,
437
+ )
438
+ except NotImplementedError as e:
439
+ result.simsimd_seconds = e
440
+ except Exception as e:
441
+ # This is an unexpected exception... once you face it, please report it
442
+ raise RuntimeError(str(e) + " for %s(%s)" % (kernel.name, str(kernel.dtype))) from e
443
+
444
+ yield result
445
+
446
+
447
+ def yield_all_pairs_results(
448
+ count_vectors_per_matrix: int,
449
+ ndim: int,
450
+ kernels: List[Kernel],
451
+ warmup: int = 0,
452
+ ) -> Generator[Result, None, None]:
453
+ # For each of the present data types, we may want to pre-generate several random matrices
454
+ count_matrices_per_dtype = 16
455
+ count_repetitions_per_matrix = 3 # This helps dampen the effect of time-measurement itself
456
+ matrices_per_dtype = {}
457
+ for kernel in kernels:
458
+ if kernel.dtype in matrices_per_dtype:
459
+ continue
460
+ matrices = [
461
+ random_matrix(count_vectors_per_matrix, ndim, kernel.dtype) for _ in range(count_matrices_per_dtype)
462
+ ]
463
+ matrices_per_dtype[kernel.dtype] = matrices
464
+
465
+ # For each kernel, repeat benchmarks for each data type
466
+ for kernel in kernels:
467
+ matrices_numpy = matrices_per_dtype[kernel.dtype]
468
+ matrices_converted = [kernel.tensor_type(m) for m in matrices_numpy]
469
+ baseline_one_to_one_func = kernel.baseline_all_pairs_func
470
+ simsimd_func = kernel.simsimd_all_pairs_func
471
+ result = Result(
472
+ kernel.dtype,
473
+ kernel.name,
474
+ baseline_seconds=0,
475
+ simsimd_seconds=0,
476
+ bytes_per_vector=matrices_numpy[0].nbytes // count_vectors_per_matrix,
477
+ distance_calculations=(count_vectors_per_matrix**2)
478
+ * count_matrices_per_dtype
479
+ * count_repetitions_per_matrix,
480
+ )
481
+
482
+ # Try obtaining the baseline measurements
483
+ try:
484
+ for i in range(1, count_matrices_per_dtype):
485
+ result.baseline_seconds += latency(
486
+ baseline_one_to_one_func,
487
+ matrices_converted[i - 1],
488
+ matrices_converted[i],
489
+ count_repetitions_per_matrix,
490
+ warmup,
491
+ )
492
+ except NotImplementedError as e:
493
+ result.baseline_seconds = e
494
+ except ValueError as e:
495
+ result.baseline_seconds = e #! This happens often during overflows
496
+ except RuntimeError as e:
497
+ result.baseline_seconds = e #! This happens often during overflows
498
+ except Exception as e:
499
+ # This is an unexpected exception... once you face it, please report it
500
+ raise RuntimeError(str(e) + " for %s(%s)" % (kernel.name, str(kernel.dtype))) from e
501
+
502
+ # Try obtaining the SimSIMD measurements
503
+ try:
504
+ for i in range(1, count_matrices_per_dtype):
505
+ result.simsimd_seconds += latency(
506
+ simsimd_func,
507
+ matrices_numpy[i - 1],
508
+ matrices_numpy[i],
509
+ count_repetitions_per_matrix,
510
+ warmup,
511
+ )
512
+ except NotImplementedError as e:
513
+ result.simsimd_seconds = e
514
+ except Exception as e:
515
+ # This is an unexpected exception... once you face it, please report it
516
+ raise RuntimeError(str(e) + " for %s(%s)" % (kernel.name, str(kernel.dtype))) from e
517
+
518
+ yield result
519
+
520
+
521
+ def result_to_row(result: Result) -> List[str]:
522
+ dtype_cell = f"`{result.dtype}`"
523
+ name_cell = f"`{result.name}`"
524
+ baseline_cell = "💥"
525
+ simsimd_cell = "💥"
526
+ improvement_cell = "🤷"
527
+
528
+ if isinstance(result.baseline_seconds, float):
529
+ ops_per_second = result.distance_calculations / result.baseline_seconds
530
+ gbs_per_second = result.bytes_per_vector * ops_per_second / 1e9
531
+ baseline_cell = f"{ops_per_second:,.0f} ops/s, {gbs_per_second:,.2f} GB/s"
532
+ if isinstance(result.simsimd_seconds, float):
533
+ ops_per_second = result.distance_calculations / result.simsimd_seconds
534
+ gbs_per_second = result.bytes_per_vector * ops_per_second / 1e9
535
+ simsimd_cell = f"{ops_per_second:,.0f} ops/s, {gbs_per_second:,.2f} GB/s"
536
+ if isinstance(result.baseline_seconds, float) and isinstance(result.simsimd_seconds, float):
537
+ improvement_cell = f"{result.baseline_seconds / result.simsimd_seconds:,.2f} x"
538
+
539
+ return [dtype_cell, name_cell, baseline_cell, simsimd_cell, improvement_cell]
540
+
541
+
542
+ def main():
543
+ # Argument parsing
544
+ parser = argparse.ArgumentParser(description="Benchmark SimSIMD and other libraries")
545
+ parser.add_argument(
546
+ "--ndim",
547
+ type=int,
548
+ default=1536,
549
+ help="""
550
+ Number of dimensions in vectors (default: 1536)
551
+
552
+ For binary vectors (e.g., Hamming, Jaccard), this is the number of bits.
553
+ In case of SimSIMD, the inputs will be treated at the bit-level.
554
+ Other packages will be matching/comparing 8-bit integers.
555
+ The volume of exchanged data will be identical, but the results will differ.
556
+ """,
557
+ )
558
+ parser.add_argument(
559
+ "-n",
560
+ "--count",
561
+ type=int,
562
+ default=1,
563
+ help="""
564
+ Number of vectors per batch (default: 1)
565
+
566
+ By default, when set to 1 the latency will generate many vectors of size (ndim, )
567
+ and call the functions on pairs of single vectors: both directly, and through `cdist`.
568
+ Alternatively, for larger batch sizes the latency will generate two matrices of
569
+ size (n, ndim) and compute:
570
+
571
+ - batch mode: (n) distances between vectors in identical rows of the two matrices,
572
+ - all-pairs mode: (n^2) distances between all pairs of vectors in the two matrices via `cdist`.
573
+ """,
574
+ )
575
+ parser.add_argument(
576
+ "--mode",
577
+ choices=["batch", "all-pairs"],
578
+ default="batch",
579
+ help="""Choose between 'batch' and 'all-pairs' mode (default: batch)
580
+
581
+ In 'batch' mode, the latency will generate two matrices of size (n, ndim)
582
+ and compute (n) distances between vectors in identical rows of the two matrices.
583
+ In 'all-pairs' mode, the latency will generate two matrices of size (n, ndim)
584
+ and compute (n^2) distances between all pairs of vectors in the two matrices via `cdist`.
585
+ """,
586
+ )
587
+ parser.add_argument(
588
+ "--metric",
589
+ choices=["all", *metric_families],
590
+ default="all",
591
+ help="Distance metric to use, profiles everything by default",
592
+ )
593
+ parser.add_argument(
594
+ "--dtype",
595
+ choices=["all", *dtype_names],
596
+ default="all",
597
+ help="Defines numeric types to latency, profiles everything by default",
598
+ )
599
+ parser.add_argument("--scipy", action="store_true", help="Profile SciPy, must be installed")
600
+ parser.add_argument("--scikit", action="store_true", help="Profile scikit-learn, must be installed")
601
+ parser.add_argument("--torch", action="store_true", help="Profile PyTorch, must be installed")
602
+ parser.add_argument("--tf", action="store_true", help="Profile TensorFlow, must be installed")
603
+ parser.add_argument("--jax", action="store_true", help="Profile JAX, must be installed")
604
+ parser.add_argument(
605
+ "--time-limit",
606
+ type=float,
607
+ default=1.0,
608
+ help="Maximum time in seconds to run each latency (default: 1.0)",
609
+ )
610
+ parser.add_argument(
611
+ "--warmup",
612
+ type=int,
613
+ default=0,
614
+ help="""
615
+ Number of warm-up runs before timing (default: 0)
616
+
617
+ This will greatly affect the results for all heavy libraries relying on JIT compilation
618
+ or lazy computational graphs (e.g., TensorFlow, PyTorch, JAX).
619
+ """,
620
+ )
621
+ args = parser.parse_args()
622
+ assert args.count > 0, "Number of vectors per batch must be greater than 0"
623
+ assert args.ndim > 0, "Number of dimensions must be greater than 0"
624
+
625
+ count = args.count
626
+ ndim = args.ndim
627
+ dtypes_profiled = set([args.dtype] if args.dtype != "all" else dtype_names)
628
+ metric_families_profiled = set([args.metric] if args.metric != "all" else metric_families)
629
+
630
+ print("# Benchmarking SimSIMD")
631
+ print("- Vector dimensions:", ndim)
632
+ print("- Vectors count:", count)
633
+ print("- Metrics:", ", ".join(metric_families_profiled))
634
+ print("- Datatypes:", ", ".join(dtypes_profiled))
635
+ try:
636
+ caps = [cap for cap, enabled in simd.get_capabilities().items() if enabled]
637
+ print("- Hardware capabilities:", ", ".join(caps))
638
+
639
+ # Log versions of SimSIMD, NumPy, SciPy, and scikit-learn
640
+ print(f"- SimSIMD version: {simd.__version__}")
641
+ print(f"- NumPy version: {np.__version__}")
642
+
643
+ if args.scipy:
644
+ import scipy as sp
645
+
646
+ print(f"- SciPy version: {sp.__version__}")
647
+ if args.scikit:
648
+ import sklearn as sk
649
+
650
+ print(f"- scikit-learn version: {sk.__version__}")
651
+ if args.torch:
652
+ import torch
653
+
654
+ print(f"- PyTorch version: {torch.__version__}")
655
+ if args.tf:
656
+ import tensorflow as tf
657
+
658
+ print(f"- TensorFlow version: {tf.__version__}")
659
+ if args.jax:
660
+ import jax
661
+
662
+ print(f"- JAX version: {jax.__version__}")
663
+
664
+ deps: dict = np.show_config(mode="dicts").get("Build Dependencies")
665
+ print("-- NumPy BLAS dependency:", deps["blas"]["name"])
666
+ print("-- NumPy LAPACK dependency:", deps["lapack"]["name"])
667
+ except Exception as e:
668
+ print(f"An error occurred: {e}")
669
+
670
+ kernels: List[Kernel] = list(
671
+ yield_kernels(
672
+ metric_families_profiled,
673
+ dtypes_profiled,
674
+ include_scipy=args.scipy,
675
+ include_scikit=args.scikit,
676
+ include_torch=args.torch,
677
+ include_tf=args.tf,
678
+ include_jax=args.jax,
679
+ )
680
+ )
681
+
682
+ results: Generator[Result, None, None] = []
683
+ if args.mode == "batch":
684
+ print("## Between Vectors in Two Matrices, Batch Size: {:,}".format(count))
685
+ results = yield_batch_results(count, ndim, kernels)
686
+ else:
687
+ print("## Between All Pairs of Vectors (`cdist`), Batch Size: {:,}".format(count))
688
+ results = yield_all_pairs_results(count, ndim, kernels)
689
+
690
+ columns_headers = ["Data Type", "Method", "Baseline", "SimSIMD", "Improvement"]
691
+ results_rows = []
692
+ for result in results:
693
+ result_row = result_to_row(result)
694
+ results_rows.append(result_row)
695
+
696
+ print(tabulate.tabulate(results_rows, headers=columns_headers))
697
+
698
+
699
+ if __name__ == "__main__":
700
+ main()