usearch 2.5.0 → 2.6.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.
@@ -591,8 +591,10 @@ jobs:
591
591
  GITHUB_PACKAGES_SOURCE: https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json
592
592
  steps:
593
593
  - uses: actions/checkout@v3
594
+ with:
595
+ ref: 'main'
594
596
  - run: git submodule update --init --recursive
595
-
597
+
596
598
  - name: Download usearch libs artifact
597
599
  uses: actions/download-artifact@v3
598
600
  with:
@@ -606,8 +608,7 @@ jobs:
606
608
 
607
609
  - name: Pack project
608
610
  run: |
609
- VERSION_CONTENT=$(< VERSION)
610
- dotnet pack "${{ env.SOLUTION }}" -c Release -p:Version="$VERSION_CONTENT" --output ${{ env.NUGET_PACKAGES }}
611
+ dotnet pack "${{ env.SOLUTION }}" -c Release --output ${{ env.NUGET_PACKAGES }}
611
612
  working-directory: ${{ github.workspace }}
612
613
 
613
614
  - name: Publish to NuGet
@@ -145,6 +145,7 @@
145
145
  "SLOC",
146
146
  "sorensen",
147
147
  "tanimoto",
148
+ "tqdm",
148
149
  "uninitialize",
149
150
  "unumusearch",
150
151
  "usearch",
package/CITATION.cff CHANGED
@@ -5,7 +5,7 @@ authors:
5
5
  given-names: "Ash"
6
6
  orcid: "https://orcid.org/0000-0002-4882-1815"
7
7
  title: "USearch by Unum Cloud"
8
- version: 2.5.0
8
+ version: 2.6.0
9
9
  doi: 10.5281/zenodo.7949416
10
10
  date-released: 2022-06-12
11
11
  url: "https://github.com/unum-cloud/usearch"
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "usearch"
3
- version = "2.5.0"
3
+ version = "2.6.0"
4
4
  authors = ["Ash 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
@@ -392,7 +392,7 @@ doi = {10.5281/zenodo.7949416},
392
392
  author = {Vardanian, Ash},
393
393
  title = {{USearch by Unum Cloud}},
394
394
  url = {https://github.com/unum-cloud/usearch},
395
- version = {2.5.0},
395
+ version = {2.6.0},
396
396
  year = {2022},
397
397
  month = jun,
398
398
  }
package/conanfile.py CHANGED
@@ -7,7 +7,7 @@ required_conan_version = ">=1.53.0"
7
7
  class USearchConan(ConanFile):
8
8
 
9
9
  name = "usearch"
10
- version = '2.5.0'
10
+ version = '2.6.0'
11
11
  license = "Apache-2.0"
12
12
  description = "Smaller & Faster Single-File Vector Search Engine from Unum"
13
13
  homepage = "https://github.com/unum-cloud/usearch"
@@ -20,6 +20,8 @@ if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
20
20
  set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -march=native")
21
21
  set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fmax-errors=1")
22
22
  set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pedantic")
23
+ set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fdiagnostics-color=always")
24
+ set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -ftime-report")
23
25
 
24
26
  if(${USEARCH_USE_OPENMP})
25
27
  set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fopenmp")
@@ -32,6 +34,8 @@ elseif(CMAKE_CXX_COMPILER_ID MATCHES "Clang")
32
34
  set(CMAKE_CXX_FLAGS_DEBUG "${CMAKE_CXX_FLAGS_DEBUG} -fsanitize=address -fsanitize=leak -fsanitize=alignment -fsanitize=undefined")
33
35
  set(CMAKE_CXX_FLAGS_DEBUG "${CMAKE_CXX_FLAGS_DEBUG} -Wfatal-errors")
34
36
  set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pedantic")
37
+ set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fcolor-diagnostics")
38
+ set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -ftime-trace")
35
39
 
36
40
  if(${USEARCH_USE_OPENMP})
37
41
  set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fopenmp")
@@ -1,7 +1,7 @@
1
1
  <?xml version="1.0" encoding="utf-8"?>
2
2
  <Project>
3
3
  <PropertyGroup>
4
- <Version Condition="'$(Version)' == ''">2.5.0</Version>
4
+ <Version Condition="'$(Version)' == ''">2.6.0</Version>
5
5
 
6
6
  <Authors>Unum</Authors>
7
7
  <Company>Unum</Company>
@@ -10,7 +10,7 @@
10
10
  #define UNUM_USEARCH_HPP
11
11
 
12
12
  #define USEARCH_VERSION_MAJOR 2
13
- #define USEARCH_VERSION_MINOR 5
13
+ #define USEARCH_VERSION_MINOR 6
14
14
  #define USEARCH_VERSION_PATCH 0
15
15
 
16
16
  // Inferring C++ version
@@ -1,15 +1,18 @@
1
1
  #pragma once
2
2
  #include <stdlib.h> // `aligned_alloc`
3
3
 
4
- #include <functional> // `std::function`
5
- #include <numeric> // `std::iota`
6
- #include <shared_mutex> // `std::shared_mutex`
7
- #include <thread> // `std::thread`
8
- #include <vector> // `std::vector`
4
+ #include <functional> // `std::function`
5
+ #include <numeric> // `std::iota`
6
+ #include <thread> // `std::thread`
7
+ #include <vector> // `std::vector`
9
8
 
10
9
  #include <usearch/index.hpp>
11
10
  #include <usearch/index_plugins.hpp>
12
11
 
12
+ #if defined(USEARCH_DEFINED_CPP17)
13
+ #include <shared_mutex> // `std::shared_mutex`
14
+ #endif
15
+
13
16
  namespace unum {
14
17
  namespace usearch {
15
18
 
@@ -383,7 +386,11 @@ class index_dense_gt {
383
386
  /// @brief Mutex, controlling concurrent access to `available_threads_`.
384
387
  mutable std::mutex available_threads_mutex_;
385
388
 
389
+ #if defined(USEARCH_DEFINED_CPP17)
390
+ using shared_mutex_t = std::shared_mutex;
391
+ #else
386
392
  using shared_mutex_t = unfair_shared_mutex_t;
393
+ #endif
387
394
  using shared_lock_t = shared_lock_gt<shared_mutex_t>;
388
395
  using unique_lock_t = std::unique_lock<shared_mutex_t>;
389
396
 
@@ -42,14 +42,10 @@
42
42
 
43
43
  #if USEARCH_USE_SIMSIMD
44
44
  #if defined(USEARCH_DEFINED_LINUX)
45
- #define SIMSIMD_TARGET_X86_AVX2 1
46
- #define SIMSIMD_TARGET_X86_AVX512 1
47
- #define SIMSIMD_TARGET_ARM_NEON 1
48
- #define SIMSIMD_TARGET_ARM_SVE 1
49
45
  #include <simsimd/simsimd.h>
50
46
  #elif defined(USEARCH_DEFINED_APPLE)
51
- #define SIMSIMD_TARGET_X86_AVX2 1
52
- #define SIMSIMD_TARGET_ARM_NEON 1
47
+ #define SIMSIMD_TARGET_X86_AVX512 0
48
+ #define SIMSIMD_TARGET_ARM_SVE 0
53
49
  #include <simsimd/simsimd.h>
54
50
  #endif
55
51
  #endif
@@ -1606,7 +1602,7 @@ class flat_hash_multi_set_gt {
1606
1602
  auto slot_pointer = bucket_pointer + sizeof(bucket_header_t) + sizeof(element_t) * in_bucket_index;
1607
1603
  return {
1608
1604
  *reinterpret_cast<bucket_header_t*>(bucket_pointer),
1609
- std::uint64_t(1ull) << in_bucket_index,
1605
+ static_cast<std::uint64_t>(1ull) << in_bucket_index,
1610
1606
  *reinterpret_cast<element_t*>(slot_pointer),
1611
1607
  };
1612
1608
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "usearch",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "Smaller & Faster Single-File Vector Search Engine from Unum",
5
5
  "author": "Ash Vardanian",
6
6
  "license": "Apache 2.0",
package/python/lib.cpp CHANGED
@@ -526,55 +526,76 @@ static void search_typed_brute_force( //
526
526
  byte_t const* dataset_data = reinterpret_cast<byte_t const*>(dataset_info.ptr);
527
527
  byte_t const* queries_data = reinterpret_cast<byte_t const*>(queries_info.ptr);
528
528
  for (std::size_t query_idx = 0; query_idx != queries_count; ++query_idx)
529
- counts_py1d(query_idx) = 0;
529
+ counts_py1d(query_idx) = wanted;
530
530
 
531
531
  if (!threads)
532
532
  threads = std::thread::hardware_concurrency();
533
533
 
534
534
  std::size_t tasks_count = static_cast<std::size_t>(dataset_count * queries_count);
535
- bitset_t query_mutexes(static_cast<std::size_t>(queries_count));
536
- if (!query_mutexes)
537
- throw std::bad_alloc();
535
+ executor_default_t executor{threads};
538
536
 
539
537
  // Progress status
540
538
  progress_t progress_{progress};
541
539
  std::atomic<std::size_t> processed{0};
542
540
 
543
- executor_default_t{threads}.dynamic(tasks_count, [&](std::size_t thread_idx, std::size_t task_idx) {
544
- //
545
- std::size_t dataset_idx = task_idx / queries_count;
546
- std::size_t query_idx = task_idx % queries_count;
547
-
541
+ struct dense_key_and_distance_t {
542
+ u32_t offset;
543
+ f32_t distance;
544
+ };
545
+ auto less = [](dense_key_and_distance_t const& a, dense_key_and_distance_t const& b) {
546
+ return a.distance < b.distance;
547
+ };
548
+
549
+ // Allocate temporary memory to store the distance matrix
550
+ // Previous version didn't need temporary memory, but the performance was much lower.
551
+ // In the new design we keep two buffers - original and transposed, as in-place transpositions
552
+ // of non-rectangular matrixes is expensive.
553
+ std::vector<dense_key_and_distance_t> keys_and_distances(tasks_count * 2);
554
+ dense_key_and_distance_t* keys_and_distances_per_dataset = keys_and_distances.data();
555
+ dense_key_and_distance_t* keys_and_distances_per_query = keys_and_distances_per_dataset + tasks_count;
556
+ executor.dynamic(dataset_count, [&](std::size_t thread_idx, std::size_t dataset_idx) {
548
557
  byte_t const* dataset = dataset_data + dataset_idx * dataset_info.strides[0];
558
+ for (std::size_t query_idx = 0; query_idx != queries_count; ++query_idx) {
549
559
  byte_t const* query = queries_data + query_idx * queries_info.strides[0];
550
560
  distance_t distance = metric(dataset, query);
551
-
552
- {
553
- auto lock = query_mutexes.lock(query_idx);
554
- dense_key_t* keys = &keys_py2d(query_idx, 0);
555
- distance_t* distances = &distances_py2d(query_idx, 0);
556
- std::size_t& matches = reinterpret_cast<std::size_t&>(counts_py1d(query_idx));
557
- if (matches == wanted)
558
- if (distances[wanted - 1] <= distance)
559
- return true;
560
-
561
- std::size_t offset = std::lower_bound(distances, distances + matches, distance) - distances;
562
-
563
- std::size_t count_worse = matches - offset - (wanted == matches);
564
- std::memmove(keys + offset + 1, keys + offset, count_worse * sizeof(dense_key_t));
565
- std::memmove(distances + offset + 1, distances + offset, count_worse * sizeof(distance_t));
566
- keys[offset] = static_cast<dense_key_t>(dataset_idx);
567
- distances[offset] = distance;
568
- matches += matches != wanted;
561
+ std::size_t task_idx = dataset_idx * queries_count + query_idx;
562
+ keys_and_distances_per_dataset[task_idx].offset = static_cast<u32_t>(dataset_idx);
563
+ keys_and_distances_per_dataset[task_idx].distance = static_cast<f32_t>(distance);
569
564
  }
570
565
 
571
566
  // We don't want to check for signals from multiple threads
572
- ++processed;
567
+ processed += queries_count;
573
568
  if (thread_idx == 0)
574
569
  if (PyErr_CheckSignals() != 0 || !progress_(processed.load(), tasks_count))
575
570
  return false;
576
571
  return true;
577
572
  });
573
+ if (processed.load() != tasks_count)
574
+ return;
575
+
576
+ // Transpose in a single thread to avoid contention writing into the same memory buffers
577
+ for (std::size_t query_idx = 0; query_idx != queries_count; ++query_idx) {
578
+ for (std::size_t dataset_idx = 0; dataset_idx != dataset_count; ++dataset_idx) {
579
+ std::size_t from_idx = queries_count * dataset_idx + query_idx;
580
+ std::size_t to_idx = dataset_count * query_idx + dataset_idx;
581
+ keys_and_distances_per_query[to_idx] = keys_and_distances_per_dataset[from_idx];
582
+ }
583
+ }
584
+
585
+ // Partial-sort every query result
586
+ executor.fixed(queries_count, [&](std::size_t, std::size_t query_idx) {
587
+ dense_key_t* keys = &keys_py2d(query_idx, 0);
588
+ distance_t* distances = &distances_py2d(query_idx, 0);
589
+ auto start = keys_and_distances_per_query + dataset_count * query_idx;
590
+ if (wanted > 1) {
591
+ std::partial_sort(start, start + wanted, start + dataset_count, less);
592
+ for (std::size_t i = 0; i != wanted; ++i)
593
+ keys[i] = static_cast<dense_key_t>(start[i].offset), distances[i] = start[i].distance;
594
+ } else {
595
+ auto max_it = std::max_element(start, start + dataset_count, less);
596
+ keys[0] = static_cast<dense_key_t>(max_it->offset), distances[0] = max_it->distance;
597
+ }
598
+ });
578
599
 
579
600
  // At the end report the latest numbers, because the reporter thread may be finished earlier
580
601
  progress_(processed.load(), tasks_count);
@@ -602,6 +623,8 @@ static py::tuple search_many_brute_force( //
602
623
  Py_ssize_t queries_dimensions = queries_info.shape[1];
603
624
  if (dataset_dimensions != queries_dimensions)
604
625
  throw std::invalid_argument("The number of vector dimensions doesn't match!");
626
+ if (wanted > dataset_count)
627
+ throw std::invalid_argument("You can't request more matches than in the dataset!");
605
628
 
606
629
  scalar_kind_t dataset_kind = numpy_string_to_kind(dataset_info.format);
607
630
  scalar_kind_t queries_kind = numpy_string_to_kind(queries_info.format);
@@ -0,0 +1,44 @@
1
+ from time import time
2
+
3
+ from usearch.index import search, MetricKind, ScalarKind
4
+ from usearch.compiled import hardware_acceleration
5
+ from faiss import IndexFlatL2, knn
6
+ import numpy as np
7
+ import fire
8
+
9
+
10
+ def run(
11
+ n: int = 10**5,
12
+ q: int = 10,
13
+ k: int = 100,
14
+ ndim: int = 256,
15
+ half: bool = False,
16
+ ):
17
+ usearch_dtype = ScalarKind.F16 if half else ScalarKind.F32
18
+ acceleration = hardware_acceleration(
19
+ dtype=usearch_dtype,
20
+ ndim=ndim,
21
+ metric_kind=MetricKind.L2sq,
22
+ )
23
+ print("Hardware acceleration in USearch: ", acceleration)
24
+
25
+ x = np.random.random((n, ndim))
26
+ x = x.astype(np.float16) if half else x.astype(np.float32)
27
+
28
+ start = time()
29
+ _ = search(
30
+ x,
31
+ x[:q],
32
+ k,
33
+ MetricKind.L2sq,
34
+ exact=True,
35
+ ).keys
36
+ print("USearch: ", time() - start)
37
+
38
+ start = time()
39
+ _ = knn(x, x[:q], k)[1]
40
+ print("FAISS: ", time() - start)
41
+
42
+
43
+ if __name__ == "__main__":
44
+ fire.Fire(run)
@@ -11,7 +11,7 @@ from usearch.index import Match, Matches, BatchMatches, Index, Indexes
11
11
 
12
12
 
13
13
  dimensions = [3, 97, 256]
14
- batch_sizes = [1, 77]
14
+ batch_sizes = [1, 77, 100]
15
15
 
16
16
 
17
17
  @pytest.mark.parametrize("rows", batch_sizes)
@@ -56,15 +56,16 @@ def test_exact_search(rows: int, cols: int):
56
56
  :param int cols: The number of columns in the matrix.
57
57
  """
58
58
  original = np.random.rand(rows, cols)
59
- matches: BatchMatches = search(original, original, 10, exact=True)
59
+ keys = np.arange(rows)
60
+ matches: BatchMatches = search(original, original, min(10, rows), exact=True)
60
61
  top_matches = (
61
62
  [int(m.keys[0]) for m in matches] if rows > 1 else int(matches.keys[0])
62
63
  )
63
- assert np.all(top_matches == np.arange(rows))
64
+ assert np.all(top_matches == keys)
64
65
 
65
- matches: Matches = search(original, original[0], 10, exact=True)
66
+ matches: Matches = search(original, original[-1], min(10, rows), exact=True)
66
67
  top_match = int(matches.keys[0])
67
- assert top_match == 0
68
+ assert top_match == keys[-1]
68
69
 
69
70
 
70
71
  def test_matches_creation_and_methods():
@@ -180,7 +180,7 @@ def _search_in_compiled(
180
180
  vectors = vectors.reshape(1, len(vectors))
181
181
  count_vectors = vectors.shape[0]
182
182
 
183
- def distil_batch(
183
+ def distill_batch(
184
184
  batch_matches: BatchMatches,
185
185
  ) -> Union[BatchMatches, Matches]:
186
186
  return batch_matches[0] if count_vectors == 1 else batch_matches
@@ -188,22 +188,22 @@ def _search_in_compiled(
188
188
  # Create progress bar if needed
189
189
  if log:
190
190
  name = log if isinstance(log, str) else "Search"
191
- pbar = tqdm(
191
+ progress_bar = tqdm(
192
192
  desc=name,
193
193
  total=count_vectors,
194
194
  unit="vector",
195
195
  )
196
196
 
197
197
  def update_progress_bar(processed: int, total: int) -> bool:
198
- pbar.update(processed - pbar.n)
198
+ progress_bar.update(processed - progress_bar.n)
199
199
  return progress if progress else True
200
200
 
201
201
  tuple_ = compiled_callable(vectors, progress=update_progress_bar, **kwargs)
202
- pbar.close()
202
+ progress_bar.close()
203
203
  else:
204
204
  tuple_ = compiled_callable(vectors, **kwargs)
205
205
 
206
- return distil_batch(BatchMatches(*tuple_))
206
+ return distill_batch(BatchMatches(*tuple_))
207
207
 
208
208
 
209
209
  def _add_to_compiled(
@@ -871,6 +871,10 @@ class Index:
871
871
  def serialized_length(self) -> int:
872
872
  return self._compiled.serialized_length
873
873
 
874
+ @property
875
+ def metric_kind(self) -> Union[MetricKind, CompiledMetric]:
876
+ return self._metric_jit if self._metric_jit.kind else self._metric_kind
877
+
874
878
  @property
875
879
  def metric(self) -> Union[MetricKind, CompiledMetric]:
876
880
  return self._metric_jit if self._metric_jit else self._metric_kind
@@ -1190,6 +1194,7 @@ class Index:
1190
1194
  "size": self.size,
1191
1195
  "jit": self.jit,
1192
1196
  "hardware_acceleration": self.hardware_acceleration,
1197
+ "metric_kind": self.metric_kind,
1193
1198
  "dtype": self.dtype,
1194
1199
  "path": self.path,
1195
1200
  "compiled_with_openmp": USES_OPENMP,
@@ -1204,7 +1209,7 @@ class Index:
1204
1209
  return f.format(
1205
1210
  self.dtype,
1206
1211
  self.ndim,
1207
- self.metric,
1212
+ self.metric_kind,
1208
1213
  self.connectivity,
1209
1214
  self.expansion_add,
1210
1215
  self.expansion_search,
@@ -1225,7 +1230,7 @@ class Index:
1225
1230
  "- config",
1226
1231
  f"-- data type: {self.dtype}",
1227
1232
  f"-- dimensions: {self.ndim}",
1228
- f"-- metric: {self.metric}",
1233
+ f"-- metric: {self.metric_kind}",
1229
1234
  f"-- connectivity: {self.connectivity}",
1230
1235
  f"-- expansion on addition:{self.expansion_add} candidates",
1231
1236
  f"-- expansion on search: {self.expansion_search} candidates",
@@ -1233,7 +1238,7 @@ class Index:
1233
1238
  f"-- uses OpenMP: {USES_OPENMP}",
1234
1239
  f"-- uses SimSIMD: {USES_SIMSIMD}",
1235
1240
  f"-- supports half-precision: {USES_NATIVE_F16}",
1236
- f"-- uses hardware acceletion: {self.hardware_acceleration}",
1241
+ f"-- uses hardware acceleration: {self.hardware_acceleration}",
1237
1242
  "- state",
1238
1243
  f"-- size: {self.size:,} vectors",
1239
1244
  f"-- memory usage: {self.memory_usage:,} bytes",
package/setup.py CHANGED
@@ -12,8 +12,11 @@ macros_args = []
12
12
  if sys.platform == "linux":
13
13
  compile_args.append("-std=c++17")
14
14
  compile_args.append("-O3") # Maximize performance
15
- compile_args.append("-g") # Simplify debugging
16
15
  compile_args.append("-Wno-unknown-pragmas")
16
+ compile_args.append("-fdiagnostics-color=always")
17
+
18
+ # Simplify debugging, but the normal `-g` may make builds much longer!
19
+ compile_args.append("-g1")
17
20
 
18
21
  macros_args.append(("USEARCH_USE_NATIVE_F16", "0"))
19
22
  macros_args.append(("USEARCH_USE_SIMSIMD", "1"))
@@ -27,9 +30,12 @@ if sys.platform == "darwin":
27
30
  compile_args.append("-mmacosx-version-min=10.15")
28
31
  compile_args.append("-std=c++17")
29
32
  compile_args.append("-O3") # Maximize performance
30
- compile_args.append("-g") # Simplify debugging
33
+ compile_args.append("-fcolor-diagnostics")
31
34
  compile_args.append("-Wno-unknown-pragmas")
32
35
 
36
+ # Simplify debugging, but the normal `-g` may make builds much longer!
37
+ compile_args.append("-g1")
38
+
33
39
  # Linking OpenMP requires additional preparation in CIBuildWheel
34
40
  # macros_args.append(("USEARCH_USE_OPENMP", "1"))
35
41
  # compile_args.append("-Xpreprocessor -fopenmp")
package/wasmer.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name="unum/usearch"
3
- version="2.5.0"
3
+ version="2.6.0"
4
4
  description="Smaller & Faster Single-File Vector Search Engine from Unum"
5
5
  license="Apache-2.0"
6
6
  readme="README.md"