diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 29c0f9f7..e16b1592 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -104,10 +104,10 @@ cmake -S . -B build -DRABITQ_BUILD_TESTS=ON \ -DRABITQ_BUILD_SAMPLES=OFF -DCMAKE_BUILD_TYPE=Release \ -DRABITQ_ENABLE_NATIVE_OPTIMIZATION=OFF cmake --build build --parallel 2 -ctest --test-dir build --output-on-failure +ctest --no-tests=error --test-dir build --output-on-failure ``` -The first configuration needs Git and network access to download GoogleTest. +The first configuration needs network access to download GoogleTest. Increase `--parallel 2` if your machine has enough memory for more compiler jobs. Native CPU tuning is disabled here to keep generic code portable. On Windows or Apple Silicon, use the matching commands in [the test guide](../tests/README.md#quick-start). diff --git a/.github/scripts/ci_changes.py b/.github/scripts/ci_changes.py index a3a3d9d6..7a52564e 100644 --- a/.github/scripts/ci_changes.py +++ b/.github/scripts/ci_changes.py @@ -55,7 +55,6 @@ def selects_core(path): "build_indexes", "download_datasets", "install_baselines", - "plot_homepage", "plot_results", "query_index", "reproduce", diff --git a/.github/scripts/test_ci_changes.py b/.github/scripts/test_ci_changes.py index be376042..cc1ee247 100644 --- a/.github/scripts/test_ci_changes.py +++ b/.github/scripts/test_ci_changes.py @@ -67,7 +67,7 @@ def test_known_support_paths_do_not_select_unrelated_builds(self): ".github/ISSUE_TEMPLATE/bug_report.yml": set(), ".github/CODEOWNERS": set(), ".github/release.yml": {"python_quality"}, - "benchmarks/plot_homepage.py": {"python_quality"}, + "benchmarks/plot_results.py": {"python_quality"}, "sample/run_cpp.sh": {"shell"}, "sample/run_python.sh": {"shell"}, "example.sh": {"shell"}, # Deleted paths still occur in a push diff. @@ -87,7 +87,7 @@ def test_known_support_paths_do_not_select_unrelated_builds(self): self.assertEqual(classify([path]), expected) def test_support_paths_do_not_mask_source_or_unknown_changes(self): - support = [".github/ISSUE_TEMPLATE/config.yml", "benchmarks/plot_homepage.py"] + support = [".github/ISSUE_TEMPLATE/config.yml", "benchmarks/plot_results.py"] self.assertEqual( classify(support + ["src/core/api.cpp"]), { @@ -364,7 +364,6 @@ def test_tagged_main_routing_changes_preserve_lightweight_companion_checks(self) for companions, expected in ( (["scripts/check-python.sh"], {"python_quality", "shell"}), (["sample/run_cpp.sh"], {"python_quality", "shell"}), - (["benchmarks/plot_homepage.py"], {"python_quality"}), (["benchmarks/build_indexes.py"], {"python_quality"}), ([".github/ISSUE_TEMPLATE/config.yml"], {"python_quality"}), (["src/core/api.cpp"], set(GROUPS)), diff --git a/.github/scripts/test_ci_workflow_contracts.py b/.github/scripts/test_ci_workflow_contracts.py index b4b9d2c3..8b56366d 100644 --- a/.github/scripts/test_ci_workflow_contracts.py +++ b/.github/scripts/test_ci_workflow_contracts.py @@ -869,6 +869,7 @@ def test_documentation_summary_is_available_for_non_doc_prs(self): self.assertEqual(needs(build), ["changes"]) self.assertIn("needs.changes.outputs.docs == 'true'", build["if"]) self.assertIn("needs.changes.result != 'success'", build["if"]) + self.assertIn("github.event_name == 'push'", build["if"]) self.assertEqual(classify(["docs/docs/quick_start.md"]), {"docs"}) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index b9a6fa00..bcb41ed4 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -19,7 +19,9 @@ jobs: build: needs: changes - if: ${{ !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.docs == 'true') }} + # Main pushes always deploy: a newer push can cancel a docs-changing run, and + # its own diff would otherwise leave the site at the older commit. + if: ${{ !cancelled() && (github.event_name == 'push' || needs.changes.result != 'success' || needs.changes.outputs.docs == 'true') }} name: Build documentation runs-on: ubuntu-latest timeout-minutes: 10 diff --git a/AGENTS.md b/AGENTS.md index b8767b24..e5b94d10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,7 +63,7 @@ Use `ctest --no-tests=error`, including filtered and installed-consumer runs. C++17 quantization/ANN library with Python bindings (`rabitqlib`). Components depend in one direction: `index` → `fastscan` → `core`; core and FastScan support OpenMP OFF, ANN/Python require ON. -Stable float32 v1 API: `include/rabitqlib/core/api.hpp`. Implementations: `src/{core,simd,fastscan,index}/`. +Stable float32 v1 API: `include/rabitqlib/core/api.hpp`. Implementations: `src/{core,simd,utils,fastscan,index}/`. Bindings: `python_bindings/`; tests: `tests/{unit,python}/`; site sources: `docs/docs/`. See the [full code map](docs/development.md#project-and-code-map) when locating unfamiliar code. diff --git a/README.md b/README.md index 80697cfb..70bea351 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
-

RaBitQ Library logo

+

RaBitQ Library logo

PyPI @@ -12,17 +12,17 @@

RaBitQ-Library is a research-backed library for vector quantization and -approximate nearest-neighbor search. See the [research papers](docs/docs/papers.md). +approximate nearest-neighbor search. See the [research papers](https://vectordb-ntu.github.io/RaBitQ-Library/papers/). ## News - **v0.7.2:** Python type hints, expanded API documentation, path-like inputs, clearer input errors, and concise index representations. Internal quantization refactoring and streamlined regression checks preserve existing APIs and index formats. - See the [release notes](docs/docs/releases/0.7.2.md). + See the [release notes](https://vectordb-ntu.github.io/RaBitQ-Library/releases/0.7.2/). - **v0.7.0:** Modular C++ components, stable core API v1, optional OpenMP for numerical components, and unified index counts. See the - [upgrade notes](docs/docs/releases/0.7.0.md). + [upgrade notes](https://vectordb-ntu.github.io/RaBitQ-Library/releases/0.7.0/). ## Vector indexes @@ -33,14 +33,14 @@ QPS–recall tradeoffs. | Index | Search approach | Storage and updates | | --- | --- | --- | -| [IVF-RaBitQ](docs/docs/index/ivf.md) | Partitioned search with batch scoring. | 1–9-bit codes or raw-vector reranking; add/remove. | -| [HNSW-RaBitQ](docs/docs/index/hnsw.md) | Graph traversal over quantized vectors. | 1–9-bit codes plus graph edges; add/remove and resize. | -| [SymphonyQG](docs/docs/index/qg.md) | Graph search with batched distance estimation. | Raw or 4/8-bit storage; updates require rebuilding. | +| [IVF-RaBitQ](https://vectordb-ntu.github.io/RaBitQ-Library/index/ivf/) | Partitioned search with batch scoring. | 1–9-bit codes or raw-vector reranking; add/remove. | +| [HNSW-RaBitQ](https://vectordb-ntu.github.io/RaBitQ-Library/index/hnsw/) | Graph traversal over quantized vectors. | 1–9-bit codes plus graph edges; add/remove and resize. | +| [SymphonyQG](https://vectordb-ntu.github.io/RaBitQ-Library/index/qg/) | Graph search with batched distance estimation. | Raw or 4/8-bit storage; updates require rebuilding. | -[Native clustering methods](docs/docs/clustering.md) prepare centroids and +[Native clustering methods](https://vectordb-ntu.github.io/RaBitQ-Library/clustering/) prepare centroids and assignments for IVF-RaBitQ and HNSW-RaBitQ. -See the [benchmarks](docs/docs/benchmarks/bench.md) for measured results, +See the [benchmarks](https://vectordb-ntu.github.io/RaBitQ-Library/benchmarks/bench/) for measured results, settings, and limitations. ## Quick start @@ -53,7 +53,7 @@ python -m pip install --upgrade "rabitqlib>=0.7.2" Wheels support CPython 3.11–3.14 on Linux x86-64/ARM64, Windows x86-64, and macOS 14+ ARM64. x86-64 requires AVX2/FMA and optionally uses -AVX-512; ARM64 uses NEON. See [platform requirements and source installation](docs/docs/installation.md#requirements). +AVX-512; ARM64 uses NEON. See [platform requirements and source installation](https://vectordb-ntu.github.io/RaBitQ-Library/installation/#requirements). Build and search a small IVF-RaBitQ index with synthetic data; no dataset download is required. This example demonstrates the API, not performance. @@ -78,17 +78,17 @@ print(ids.shape, distances.shape) # (5, 10) (5, 10) IDs identify input rows; distances here estimate squared L2 distance, with smaller values first. Quantization remains approximate even when all clusters are probed. -The [complete quick start](docs/docs/quick_start.md) explains result handling, +The [complete quick start](https://vectordb-ntu.github.io/RaBitQ-Library/quick_start/) explains result handling, measures recall while varying `nprobe`, and shows save/load. -For other indexes, see the [HNSW-RaBitQ](docs/docs/index/hnsw.md#minimal-workflow) and -[SymphonyQG](docs/docs/index/qg.md#minimal-workflow) examples. +For other indexes, see the [HNSW-RaBitQ](https://vectordb-ntu.github.io/RaBitQ-Library/index/hnsw/#minimal-workflow) and +[SymphonyQG](https://vectordb-ntu.github.io/RaBitQ-Library/index/qg/#minimal-workflow) examples. ### C++ -Use the [C++ integration guide](docs/docs/cpp_integration.md) for source builds, +Use the [C++ integration guide](https://vectordb-ntu.github.io/RaBitQ-Library/cpp_integration/) for source builds, component linking, and installation. Start with the -[C++ index examples](docs/docs/cpp_integration.md#c-examples), or use the +[C++ index examples](https://vectordb-ntu.github.io/RaBitQ-Library/cpp_integration/#c-examples), or use the quantization components below in your own engine. ## Quantization components @@ -98,12 +98,12 @@ engine, independently of the included indexes. | Component | What it provides | Start here | | --- | --- | --- | -| `rabitqlib::core` | Rotation, per-vector encoding, query preparation, and distance estimation. | [Core example](sample/cpp/core/) · [Stable v1 API](docs/docs/rabitq/core_api.md) | -| `rabitqlib::fastscan` | Batch encoding and lookup-table scoring over 32-vector layouts, built on core. | [FastScan example](sample/cpp/fastscan/) | +| `rabitqlib::core` | Rotation, per-vector encoding, query preparation, and distance estimation. | [Core example](https://github.com/VectorDB-NTU/RaBitQ-Library/tree/main/sample/cpp/core) · [Stable v1 API](https://vectordb-ntu.github.io/RaBitQ-Library/rabitq/core_api/) | +| `rabitqlib::fastscan` | Batch encoding and lookup-table scoring over 32-vector layouts, built on core. | [FastScan example](https://github.com/VectorDB-NTU/RaBitQ-Library/tree/main/sample/cpp/fastscan) | -See [component integration](docs/docs/cpp_integration.md#core-only-c-integration) -for build options and compatibility, the [quantizer guide](docs/docs/rabitq/quantizer.md) -for encoding choices, and the [core benchmark](benchmarks/core/README.md) for +See [component integration](https://vectordb-ntu.github.io/RaBitQ-Library/cpp_integration/#core-only-c-integration) +for build options and compatibility, the [quantizer guide](https://vectordb-ntu.github.io/RaBitQ-Library/rabitq/quantizer/) +for encoding choices, and the [core benchmark](https://github.com/VectorDB-NTU/RaBitQ-Library/blob/main/benchmarks/core/README.md) for component throughput and estimation error. ## Adoption and resources @@ -114,8 +114,8 @@ Two examples of direct library integration: | Project | Integration | | --- | --- | -| [txtai](docs/docs/integrations/txtai.md) | Uses the Python IVF-RaBitQ and HNSW-RaBitQ indexes as an embedding-search backend. | -| [zvec](docs/docs/integrations/zvec.md) | Uses core quantization in its own IVF-RaBitQ and HNSW-RaBitQ indexes, with FastScan batch scoring in IVF-RaBitQ. | +| [txtai](https://vectordb-ntu.github.io/RaBitQ-Library/integrations/txtai/) | Uses the Python IVF-RaBitQ and HNSW-RaBitQ indexes as an embedding-search backend. | +| [zvec](https://vectordb-ntu.github.io/RaBitQ-Library/integrations/zvec/) | Uses core quantization in its own IVF-RaBitQ and HNSW-RaBitQ indexes, with FastScan batch scoring in IVF-RaBitQ. | The linked walkthroughs describe integration boundaries and provide versioned source references. @@ -191,15 +191,15 @@ listed above. ### Further reading -- [Documentation](https://vectordb-ntu.github.io/RaBitQ-Library/) and [algorithm overview](docs/docs/rabitq/rabitq.md). -- [Papers and citations](docs/docs/papers.md) for the research behind the library. -- [Release notes](https://github.com/VectorDB-NTU/RaBitQ-Library/releases) and [0.7 migration guide](docs/docs/modular_migration.md). +- [Documentation](https://vectordb-ntu.github.io/RaBitQ-Library/) and [algorithm overview](https://vectordb-ntu.github.io/RaBitQ-Library/rabitq/rabitq/). +- [Papers and citations](https://vectordb-ntu.github.io/RaBitQ-Library/papers/) for the research behind the library. +- [Release notes](https://github.com/VectorDB-NTU/RaBitQ-Library/releases) and [0.7 migration guide](https://vectordb-ntu.github.io/RaBitQ-Library/modular_migration/). - [GPU implementation](https://github.com/Stardust-SJF/cuvs_rabitq/tree/cuvs_ivf_rabitq) in cuVS. ## Contributing -Start with the [contribution guide](.github/CONTRIBUTING.md#your-first-contribution) -or [starter tasks](.github/CONTRIBUTING.md#starter-tasks). Report bugs and request +Start with the [contribution guide](https://github.com/VectorDB-NTU/RaBitQ-Library/blob/main/.github/CONTRIBUTING.md#your-first-contribution) +or [starter tasks](https://github.com/VectorDB-NTU/RaBitQ-Library/blob/main/.github/CONTRIBUTING.md#starter-tasks). Report bugs and request features through [GitHub Issues](https://github.com/VectorDB-NTU/RaBitQ-Library/issues/new/choose). ## Authors and acknowledgements @@ -213,4 +213,4 @@ Jiabao Jin, Xiaoyao Zhong, and Jinjing Zhou for their valuable feedback. ## License -RaBitQ Library is available under the [Apache License 2.0](LICENSE). +RaBitQ Library is available under the [Apache License 2.0](https://github.com/VectorDB-NTU/RaBitQ-Library/blob/main/LICENSE). diff --git a/benchmarks/DBPEDIA_MANIFEST.json b/benchmarks/DBPEDIA_MANIFEST.json index d596abcf..bef4920a 100644 --- a/benchmarks/DBPEDIA_MANIFEST.json +++ b/benchmarks/DBPEDIA_MANIFEST.json @@ -1,6 +1,6 @@ { "dataset": "openai-1536-angular", - "source": "SuperKMeans benchmark data_openai.bin and data_openai_test.bin", + "source": "openai-1536-angular.hdf5 train/test splits (Google Drive file 1cwIY76n_HEbbZAANJVWiVkjBsIy3b9jB), converted to fvecs", "metric": "inner_product", "normalization": "unit-normalized source values preserved exactly", "dimension": 1536, diff --git a/benchmarks/README.md b/benchmarks/README.md index c4ec5a71..ff751b00 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -3,8 +3,8 @@ For embedding the quantizer in another system, use the independent [core C++ benchmark](core/README.md). It needs only core and reports per-repeat timings, distance-estimate error and quantized full-scan recall. The workflows below benchmark -complete ANN/clustering systems. For dispatch, encoding, FastScan, sidecar loading and -Python ID-conversion regression measurements, use the +complete ANN/clustering systems. For dispatch, encoding, FastScan, sidecar loading, IVF add +and Python ID-conversion regression measurements, use the [component hot-path benchmarks](hotpaths/README.md). See the [benchmark overview](../docs/docs/benchmarks/bench.md) for datasets, diff --git a/benchmarks/core/README.md b/benchmarks/core/README.md index 9f460873..1a3de18e 100644 --- a/benchmarks/core/README.md +++ b/benchmarks/core/README.md @@ -12,7 +12,7 @@ cmake -S . -B build-core-bench -DCMAKE_BUILD_TYPE=Release \ -DRABITQ_BUILD_SAMPLES=OFF -DRABITQ_USE_OPENMP=OFF \ -DRABITQ_BUILD_BENCHMARKS=ON -DRABITQ_ENABLE_NATIVE_OPTIMIZATION=OFF cmake --build build-core-bench --parallel -ctest --test-dir build-core-bench --output-on-failure +ctest --no-tests=error --test-dir build-core-bench --output-on-failure ./build-core-bench/benchmarks/core/rabitq_core_benchmark > core-results.csv ``` @@ -22,7 +22,7 @@ For an installed package, configure this directory directly: cmake -S benchmarks/core -B build-core-bench-installed \ -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH="" cmake --build build-core-bench-installed --parallel -ctest --test-dir build-core-bench-installed --output-on-failure +ctest --no-tests=error --test-dir build-core-bench-installed --output-on-failure ./build-core-bench-installed/rabitq_core_benchmark 128 4 4096 64 7 > core-results.csv ``` @@ -107,8 +107,8 @@ valid code/factor prototypes are encoded, then replicated at distinct memory add This models access and call costs, not dataset-dependent ANN quality. Inputs are already in the numerical working domain; no rotation or top-k retrieval is timed or evaluated. The metric is L2 with a zero centroid. Prototype scores must match all three references -exactly before timing. Raw timings and double-precision checksums are printed for every -path and repetition; path order rotates and each timed pass has its own warmup. +within a computed float32 rounding bound before timing. Raw timings and +double-precision checksums are printed for every path and repetition; path order rotates and each timed pass has its own warmup. Compare baseline/candidate binaries with identical harness source, compiler flags and parameters. Alternate process order, retain all samples, and verify checksums alongside diff --git a/benchmarks/hotpaths/README.md b/benchmarks/hotpaths/README.md index 41d5c304..7b7a4bd4 100644 --- a/benchmarks/hotpaths/README.md +++ b/benchmarks/hotpaths/README.md @@ -11,6 +11,7 @@ Enable `RABITQ_BUILD_BENCHMARKS=ON` in a Release build. Targets follow enabled c | `rabitq_core_hotpaths` | core only | One-bit compact encoding or raw L2 calls | | `rabitq_fastscan_hotpaths` | FastScan | Cached standard/HACC batch scoring | | `rabitq_load_hotpaths` | index | HNSW centroid-sidecar load and validation | +| `rabitq_ivf_add_hotpaths` | index | One IVF `add` call on a freshly loaded index | | `rabitq_ids_benchmark` module + `ids.py` | Python bindings | Production cluster-ID conversion helper | No additional production dependencies are introduced. Core/FastScan benchmarks support @@ -27,7 +28,7 @@ cmake -S . -B build-hotpaths -DCMAKE_BUILD_TYPE=Release \ -DRABITQ_BUILD_BENCHMARKS=ON -DRABITQ_BUILD_SAMPLES=OFF \ -DRABITQ_ENABLE_NATIVE_OPTIMIZATION=OFF cmake --build build-hotpaths --parallel -ctest --test-dir build-hotpaths -R 'Hotpath' --output-on-failure +ctest --no-tests=error --test-dir build-hotpaths -R 'Hotpath' --output-on-failure ``` Set both `RABITQ_BUILD_INDEX=OFF` and `RABITQ_BUILD_FASTSCAN=OFF` for pure core. @@ -50,6 +51,9 @@ Examples (from the repository root): ./build-hotpaths/benchmarks/hotpaths/rabitq_load_hotpaths prepare "$TMPDIR/centroids" 128 16384 1 ./build-hotpaths/benchmarks/hotpaths/rabitq_load_hotpaths load "$TMPDIR/centroids" 128 16384 7 +# path dim clusters base_n add_n repetitions single|routed; path receives the base index +./build-hotpaths/benchmarks/hotpaths/rabitq_ivf_add_hotpaths "$TMPDIR/ivf-add" 64 8 256 16 3 routed + python benchmarks/hotpaths/ids.py --module-dir build-hotpaths/python_bindings ``` @@ -61,7 +65,8 @@ the deterministic input. Original production indexes are not benchmark fixtures. - IVF add verifies the expected point counts and samples new points' self-neighbors outside the timed call, in addition to comparing output hashes across repetitions. -- Inputs use an LCG with seed 42 (query/centroid seed 91 where applicable), metric L2. +- Inputs use an LCG with seed 42 (query/centroid seed 91 where applicable; IVF add uses + seed 91 for added points and seed 7 for centroids), metric L2. Dimensions for encoding/scoring are multiples of 32. Use 64, 128 and 960 dimensions; encoding rows 32/4096 and threads 1/4 retain the cases from the original investigation. - Core encoding times the existing serial template at one thread and OpenMP template diff --git a/benchmarks/plot_results.py b/benchmarks/plot_results.py index 3945ee26..5e4fdd56 100644 --- a/benchmarks/plot_results.py +++ b/benchmarks/plot_results.py @@ -12,6 +12,7 @@ matplotlib.use("Agg") import matplotlib.pyplot as plt from matplotlib.lines import Line2D +from matplotlib.ticker import FuncFormatter, LogLocator, NullLocator if __package__: from .build_indexes import CASES, DATASETS, validate_record @@ -43,6 +44,156 @@ def observations(record): ) +# Each panel compares methods with similar per-dimension storage. Colors follow +# the index type within a figure (validated categorical slots; at most three per +# figure); line style and marker separate methods that share a color. +BLUE, ORANGE, AQUA = "#2a78d6", "#eb6834", "#1baf7a" +INK, SECONDARY, MUTED = "#0b0b0b", "#52514e", "#898781" +SURFACE, GRID, AXIS = "#fcfcfb", "#e1e0d9", "#c3c2b7" +PANELS = { + "ivf": ( + ( + "Codes + float32 reranking", + ("rabitq-ivf-raw", "faiss-rabitqfs-raw", "faiss-pqfs", "faiss-opq-pqfs"), + ), + ("1 + 4 bits", ("rabitq-ivf-1p4", "faiss-rabitqfs-1p4")), + ("1 + 8 bits", ("rabitq-ivf-1p8", "faiss-rabitqfs-1p8")), + ), + "graphs": ( + ("Float32 vectors", ("rabitq-symqg-raw", "faiss-hnsw-flat")), + ("4-5 bits", ("rabitq-symqg-4", "rabitq-hnsw-1p4", "faiss-hnsw-sq4")), + ("8-9 bits", ("rabitq-symqg-8", "rabitq-hnsw-1p8", "faiss-hnsw-sq8")), + ), +} +STYLES = { + "rabitq-ivf": (BLUE, "o", "-"), + "faiss-ivf-rabitq-fs": (ORANGE, "s", "--"), + "faiss-pqfs": (AQUA, "^", "--"), + "faiss-opq-pqfs": (AQUA, "D", ":"), + "rabitq-symqg": (BLUE, "o", "-"), + "rabitq-hnsw": (AQUA, "^", "-"), + "faiss-hnsw-flat": (ORANGE, "s", "--"), + "faiss-hnsw-sq4": (ORANGE, "s", "--"), + "faiss-hnsw-sq8": (ORANGE, "s", "--"), +} +for _family, _panels in PANELS.items(): + if {name for _, names in _panels for name in names} != { + case.name for case in CASES if case.family == _family + }: + raise RuntimeError(f"plot panels do not cover the {_family} cases") + + +def case_style(case): + return STYLES.get(case.name) or STYLES[case.method] + + +def qps_label(value, _position): + return f"{value / 1000:g}K" if value >= 1000 else f"{value:g}" + + +def plot_family(measurements, dataset, family, cpu): + """Draw one dataset's QPS-recall panels for an index family.""" + cases = {case.name: case for case in CASES} + panels = PANELS[family] + series = {} + for _, names in panels: + for name in names: + points = measurements.get((dataset, name)) + if not points: + continue + for _, recall, qps in points: + if not (0 <= recall <= 1 and math.isfinite(qps) and qps > 0): + raise ValueError(f"invalid measurement: {dataset}/{name}") + series[name] = [(recall, qps) for _, recall, qps in points] + # Start at 0.5 recall, or lower so every method's best recall sits at least + # 0.1 inside the axis; settings below the start are clipped. + lowest_best = min(max(recall for recall, _ in points) for points in series.values()) + recall_min = min(0.5, math.floor((lowest_best - 0.1) * 10) / 10) + + fig, axes = plt.subplots(1, len(panels), figsize=(9, 4.7), sharey=True) + fig.patch.set_facecolor(SURFACE) + fig.subplots_adjust(left=0.085, right=0.99, top=0.81, bottom=0.31, wspace=0.16) + title = {"gist1m": "GIST1M", "dbpedia": "DBpedia"}[dataset] + family_title = "IVF indexes" if family == "ivf" else "Graph indexes" + fig.text( + 0.012, 0.962, f"{title} · {family_title}", fontsize=14, weight="bold", color=INK + ) + fig.text( + 0.012, + 0.91, + f"Recall@10 vs queries per second · 1 search thread, 1 query per call · CPU: {cpu}", + fontsize=9.5, + color=SECONDARY, + ) + for ax, (panel_title, names) in zip(axes, panels): + ax.set_facecolor(SURFACE) + handles = [] + for name in names: + if name not in series: + continue + case = cases[name] + color, marker, style = case_style(case) + points = series[name] + x, y = zip(*points) + ax.scatter(x, y, color=color, marker=marker, s=12, alpha=0.3, linewidths=0) + x, y = zip(*frontier(points)) + ax.plot( + x, + y, + color=color, + marker=marker, + markersize=4.5, + markeredgecolor=SURFACE, + markeredgewidth=0.8, + linewidth=1.5, + linestyle=style, + solid_capstyle="round", + dash_capstyle="round", + ) + handles.append( + Line2D( + [], + [], + color=color, + marker=marker, + linestyle=style, + label=case.label, + ) + ) + ax.set_title(panel_title, fontsize=10.5, color=INK, loc="left", pad=6) + ax.set_xlim(recall_min, 1.005) + ax.set_xticks( + [ + round(recall_min + 0.1 * i, 1) + for i in range(round((1 - recall_min) * 10) + 1) + ] + ) + ax.set_yscale("log") + ax.yaxis.set_major_locator(LogLocator(subs=(1, 2, 5))) + ax.yaxis.set_major_formatter(FuncFormatter(qps_label)) + ax.yaxis.set_minor_locator(NullLocator()) + ax.grid(True, which="major", color=GRID, linewidth=0.75) + ax.set_axisbelow(True) + ax.spines[["top", "right"]].set_visible(False) + ax.spines[["left", "bottom"]].set_color(AXIS) + ax.tick_params(colors=MUTED, labelcolor=SECONDARY, labelsize=9, length=0) + ax.set_xlabel("Recall@10", fontsize=9.5, color=SECONDARY) + # Below the panel, so the legend never covers measured points. + ax.legend( + handles=handles, + loc="upper left", + bbox_to_anchor=(0, -0.2), + borderaxespad=0, + frameon=False, + fontsize=8, + handlelength=2.2, + markerscale=0.8, + labelcolor=INK, + ) + axes[0].set_ylabel("Queries / second (log)", fontsize=9.5, color=SECONDARY) + return fig + + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--input-dir", type=Path, required=True) @@ -61,6 +212,7 @@ def main(): args = parser.parse_args() args.output_dir.mkdir(parents=True, exist_ok=True) measurements = {} + cpus = set() csv_path = args.output_dir / "qps-recall-points.csv" if csv_path.exists(): with csv_path.open(newline="") as previous: @@ -95,97 +247,28 @@ def main(): != initial["build"]["cpu_placement"]["allowed_cpus"] ): raise ValueError(f"sweep does not match its index build: {dataset}/{name}") + cpus.add(initial["hardware"]["cpu"]) measurements[dataset, name] = list(observations(record)) - colors = list(plt.get_cmap("tab10").colors) - markers = ("o", "s", "^", "D", "v", "P") + if len(cpus) != 1: + raise ValueError(f"selected records span CPUs {sorted(cpus)}") + (cpu,) = cpus rows = [] - plt.rcParams.update({"font.size": 10, "svg.fonttype": "none"}) + plt.rcParams.update( + { + "font.family": "sans-serif", + "font.sans-serif": ["Helvetica", "Arial", "DejaVu Sans"], + "svg.fonttype": "none", + } + ) for dataset, family in product(args.datasets, ("ivf", "graphs")): if not any( case.name in args.methods and case.family == family for case in CASES ): continue - fig, ax = plt.subplots(figsize=(12, 9)) - fig.subplots_adjust(top=0.85, bottom=0.32) - title = {"gist1m": "GIST1M", "dbpedia": "DBpedia"}[dataset] - family_title = "IVF indexes" if family == "ivf" else "Graph indexes" - fig.suptitle( - f"{title} · {family_title} | Recall@10 vs QPS", fontsize=20, y=0.975 - ) - fig.text( - 0.5, - 0.92, - "One Intel Xeon Gold 6418H CPU · one query per call", - ha="center", - color="#444444", - ) - handles = [] - recalls = [] - for ordinal, case in enumerate(case for case in CASES if case.family == family): - name = case.name - if (dataset, name) not in measurements: - continue - points = [] - for setting, recall, qps in measurements[dataset, name]: - if not (0 <= recall <= 1 and math.isfinite(qps) and qps > 0): - raise ValueError(f"invalid measurement: {dataset}/{name}") - points.append((recall, qps)) - recalls.extend(recall for recall, _ in points) - color = colors[ordinal] - marker = markers[ordinal % len(markers)] - style = "-" if name.startswith("rabitq-") else "--" - handles.append( - Line2D( - [], - [], - color=color, - marker=marker, - linestyle=style, - label=case.label, - ) - ) - if points: - x, y = zip(*points) - ax.scatter(x, y, color=color, marker=marker, s=22, alpha=0.45) - x, y = zip(*frontier(points)) - ax.plot( - x, - y, - color=color, - marker=marker, - markersize=4, - linewidth=1.4, - linestyle=style, - ) - ax.set_title( - "Query set · full sweep · 1 search thread · one timing pass", fontsize=11 - ) - ax.set_xlabel("Recall@10 (measured)") - ax.set_ylabel("Queries / second (log scale)") - recall_min = max(0, math.floor((min(recalls) - 0.005) * 20) / 20) - ax.set_xlim(recall_min, 1.005) - ax.set_yscale("log") - ax.grid(True, which="major", alpha=0.2) - ax.spines[["top", "right"]].set_visible(False) - fig.legend( - handles=handles, - loc="lower center", - bbox_to_anchor=(0.5, 0.09), - ncol=2, - frameon=False, - fontsize=9, - ) - fig.text( - 0.5, - 0.046, - "Faint markers: all measured settings. Lines: per-method Pareto frontier; no fitted or extrapolated points.", - ha="center", - fontsize=10, - color="#444444", - ) + fig = plot_family(measurements, dataset, family, cpu) path = args.output_dir / f"{family}-{dataset}-qps-recall.svg" - fig.savefig(path) + fig.savefig(path, facecolor=SURFACE) path.write_text( "\n".join(line.rstrip() for line in path.read_text().splitlines()) + "\n" ) diff --git a/docs/development.md b/docs/development.md index 8cc3b04b..5e049d76 100644 --- a/docs/development.md +++ b/docs/development.md @@ -140,6 +140,10 @@ ruff check --fix python_bindings sample/python tests/python tests/test_mypy.py t ruff format python_bindings sample/python tests/python tests/test_mypy.py tests/test_pyright.py benchmarks ``` +`check-python.sh` does not cover the CI automation in `.github/scripts`; CI also +runs `ruff format --check .github/scripts` and `ruff check .github/scripts`, so +run both after changing those scripts. + ### Shell scripts Run ShellCheck after changing a contributor or automation script: @@ -167,8 +171,10 @@ It recompiles the library sources from `compile_commands.json` with that build's (extra arguments such as `-O1` are appended) and fails when copies of a weak function differ between ISA flag sets. CI checks the Release (`-O3`) configuration; with GCC 14 the check also passes at `-O1` and `-O2 -g`, which is what the sanitizer and -RelWithDebInfo configurations compile. Debug (`-O0`) still emits standard-library -helpers (`std::min`, `std::round`, `std::fill`) per source and is not clean. The FastScan +RelWithDebInfo configurations compile. Debug (`-O0`) is not clean: it emits +standard-library helpers (`std::min`, `std::round`, `std::fill`) and small +first-party inline functions (for example `SearchBuffer` and query accessors) per +source. The FastScan estimator correction is written as plain loops rather than Eigen expressions so that no ISA source instantiates a shared Eigen helper; keep it that way, since a static library only hides such sharing while its generic objects precede the ISA objects. The script @@ -197,6 +203,7 @@ cmake -S . -B build-tidy -G Ninja \ -DRABITQ_BUILD_SAMPLES=OFF \ -DRABITQ_BUILD_TESTS=OFF \ -DRABITQ_BUILD_PYTHON_BINDINGS=ON \ + -DRABITQ_ENABLE_NATIVE_OPTIMIZATION=OFF \ -DCMAKE_BUILD_TYPE=Release \ -Dpybind11_DIR="$pybind11_DIR" CXX=clang++-23 ./scripts/check-tidy.sh build-tidy @@ -538,7 +545,9 @@ that corrupt or mismatched files are reported before allocation, without treatin crafted file as a supported input. Do not silently reinterpret an old file. Add a magic/version discriminator, use fixed-width -serialized fields for new formats, validate sizes before allocation, check every read, and retain a +serialized fields for new formats (the current IVF, HNSW and QG bodies still store several +`size_t` fields; change those only with a format version bump), validate sizes before +allocation, check every read, and retain a compatibility test fixture or an explicit rejection path. Review each affected format; changes to shared serialization helpers require auditing every format using them. IVF-RaBitQ, HNSW-RaBitQ, and SymphonyQG have separate formats. SymphonyQG includes a versioned quantized format plus a legacy @@ -703,7 +712,7 @@ public and protected names, Python keywords, field order and numerical operation role is clear. - Clustering's private state uses `dim_`, `num_points_`, `num_clusters_`, `num_workers_` and `data_`. Keep public `d`, `k`, `num_threads` and graph parameter - names compatible. Retain the existing private `niter_` graph iteration name. + names compatible. The private graph iteration count is `graph_build_iterations_`. Per-point centroid IDs are `assignments` (arrays) and `cluster_id` (scalars); `label` stays reserved for HNSW's caller-visible labels. - FHT backend arguments use `rotated_data`, `padded_dim`, `trunc_dim` and @@ -878,8 +887,10 @@ nothing in an old file is reinterpreted. A file that has removals is rejected by through 0.5.1, whose `load` range-checks every cluster ID; that rejection is the compatibility path. This mark is unrelated to the `SearchBuffer` ID marker. Keep these rules when touching HNSW-RaBitQ code: -- Read cluster IDs only through `cluster_id_of`, which masks the bit. Estimators, - routing, and the `add` reconstruction index centroids with it. +- Read cluster IDs only through the masking accessors. Internal code (estimators, + routing, and the `add` reconstruction) uses the unchecked private `stored_cluster_id`; + the public `cluster_id_of` adds storage and bounds checks and must stay out of the + per-candidate search kernels. - A removed point stays in the graph: base-layer search still inserts it into `search_pool`, and `add` may link to it. Only the result buffer skips it, including when it is the entry point. - `load` masks the bit before range-checking the cluster ID. @@ -971,8 +982,10 @@ the compiler inlines them. `scripts/check-isa-symbols.sh` checks Release builds; [ISA symbol check](#isa-symbol-check). The AVX FHT intrinsics in `src/simd/fht_kernels.hpp` use the private kernel namespace for the same reason. Rotation uses this implementation on GCC, Clang, and MSVC; -no assembler is required. Keep the FFHT attribution and MIT license in the -intrinsics header intact. +no assembler is required. Keep the FFHT attribution comment in the intrinsics +header intact. The intrinsics reimplement the transform following FFHT's stage +schedule without copying its code, so the header does not carry FFHT's MIT notice; +code copied from FFHT must bring that notice with it. #### Eigen isolation @@ -1073,7 +1086,8 @@ source groups/flags with all three dispatchers and `src/utils/cpu_features.cpp`. ### Performance and compatibility -- Use fixed-width integer types for serialized values and persisted index data. +- Use fixed-width integer types for new serialized values and persisted index data; + existing `size_t` fields change only with a format version bump. - Preserve existing public headers, aliases, and index formats unless a change is explicitly documented as breaking. - Add backend-independent tests when introducing or changing SIMD kernels. diff --git a/docs/docs/assets/img/SingleData.png b/docs/docs/assets/img/SingleData.png deleted file mode 100644 index b5bf9565..00000000 Binary files a/docs/docs/assets/img/SingleData.png and /dev/null differ diff --git a/docs/docs/benchmarks/bench.md b/docs/docs/benchmarks/bench.md index 9fa02ef6..3c06dbac 100644 --- a/docs/docs/benchmarks/bench.md +++ b/docs/docs/benchmarks/bench.md @@ -3,6 +3,52 @@ Compare RaBitQ-Library, Faiss, and SuperKMeans on one Intel Xeon Gold 6418H: [IVF indexes](ivf.md), [graph indexes](graphs.md), and [k-means](kmeans.md). +## Key results + +![GIST1M: queries per second and index size for IVF-RaBitQ, HNSW-RaBitQ and SymphonyQG](figures/homepage-gist1m.svg){ .chart } + +On GIST1M, each bar is the fastest measured setting with Recall@10 >= 90%, +without interpolation. IVF-RaBitQ/HNSW-RaBitQ use 5 total bits (one sign bit plus +four extra bits) and SymphonyQG uses 4 bits. Actual recall and total storage +differ; this is neither an exact-recall nor an equal-memory comparison. + +| Configuration | Measured Recall@10 | Queries / second | Index GiB | Selected search setting | +| --- | ---: | ---: | ---: | --- | +| IVF-RaBitQ · 5-bit | 92.82% | 1,038 | 0.60 | `nprobe=128` | +| HNSW-RaBitQ · 5-bit | 92.70% | 1,866 | 0.72 | `ef=512` | +| SymphonyQG · 4-bit | 90.36% | 3,086 | 4.39 | `ef=512` | + +These use the 0.7.1 measurements with one search thread and one query per call. +Each setting has one timing pass. QPS includes Python call overhead after warm-up +and excludes loading. Index size is serialized storage including sidecars, not +resident or peak RAM. The figure and this table are a static export: +`reproduce.py` does not regenerate them, so update both by hand after new runs. + +Full curves, baselines, construction costs and other precisions: +[IVF indexes](ivf.md) · [graph indexes](graphs.md) · [k-means](kmeans.md). + +## Reading the figures + +Recall@10 is the mean fraction of exact top-10 neighbors returned. Each figure +groups methods with similar storage per dimension into panels that share the QPS +axis. Faint markers show all measured settings within the axis range; lines connect +each method's Pareto frontier. QPS uses a logarithmic axis. The recall axis starts +at 0.5, or lower so every method's best recall sits at least 0.1 inside the axis; +settings below the start are clipped, and the CSV contains every measured point. + +## Reading the indexing tables + +| Column | Meaning | +| --- | --- | +| Train | Training or centroid clustering time | +| Construct | Encoding and index construction after training | +| Total | Construction including setup and training, excluding saving | +| Save | Serialization time | +| Index GiB | Serialized files, including sidecars and retained raw vectors | + +Times are seconds. Data preparation, ground-truth generation, and correctness +checks are outside construction timing. + ## Datasets and ground truth | Dataset | Base vectors | Dimensions | ANN metric | Queries | @@ -32,6 +78,32 @@ L2 ground truth for OpenAI. | RaBitQ-Library | 0.7.1, measured 2026-10-23 (Asia/Singapore) | | Baselines | Faiss 1.15.0, SuperKMeans 0.2.0; Intel MKL 2020.4, GNU OpenMP | +## Construction and search settings + +IVF uses 4,096 clusters. HNSW uses `M=16` and `efConstruction=200`; +SymphonyQG uses degree 32. Family pages list all method settings. + +| Family | Search grid | +| --- | --- | +| IVF | `nprobe=1,2,4,8,16,32,64,128,256,512` | +| Faiss IVF with RFlat | Above × `k_factor=1,2,5,10,20` | +| Graph | `ef=16,32,64,128,256,512,1024` | + +Each setting searches all 1,000 queries at `k=10`, after warm-up. QPS is queries +divided by elapsed search time, including Python call overhead and excluding +index loading. Each setting has one timing pass. + +## Scope and limitations + +Each method has one build or fit per dataset; results do not measure run-to-run +variation. +The committed CSVs support checking the displayed tables and plotted points, +but do not include raw run logs or measurement binaries. The 0.7.1 raw records, +logs, indexes, source/build manifest and validation logs are not published. +Historical baseline raw logs and +binaries are not included. No run-to-run or shared-host noise correction is +applied. The experiments in the cited papers are separate measurements. + ## Package installation The RaBitQ-Library rows and curves were refreshed on 2026-10-23 (Asia/Singapore) @@ -80,62 +152,23 @@ source benchmarks/env.sh The installer builds RaBitQ from the selected checkout, Faiss 1.15.0, and SuperKMeans 0.2.0 in the active environment. Both baselines link to MKL with GNU OpenMP. Build logs and verification records are saved in `build/benchmark-baselines/`. -Use `--dry-run` to preview installation commands. The installer defaults to the -historical GCC 13 toolchain; it does not recreate the GCC 14.3.0 wheel used for -the 0.7.1 measurements. Match the recorded compiler and native-optimization -setting when comparing new library timings. - -## Construction and search settings - -IVF uses 4,096 clusters. HNSW uses `M=16` and `efConstruction=200`; -SymphonyQG uses degree 32. Family pages list all method settings. - -| Family | Search grid | -| --- | --- | -| IVF | `nprobe=1,2,4,8,16,32,64,128,256,512` | -| Faiss IVF with RFlat | Above × `k_factor=1,2,5,10,20` | -| Graph | `ef=16,32,64,128,256,512,1024` | - -Each setting searches all 1,000 queries at `k=10`, after warm-up. QPS is queries -divided by elapsed search time, including Python call overhead and excluding -index loading. Each setting has one timing pass. - -## Reading the figures - -Recall@10 is the mean fraction of exact top-10 neighbors returned. Faint markers -show all measured settings; lines connect each method's Pareto frontier. QPS uses -a logarithmic axis. The CSV contains every measured point. - -## Reading the indexing tables - -| Column | Meaning | -| --- | --- | -| Train | Training or centroid clustering time | -| Construct | Encoding and index construction after training | -| Total | Construction including setup and training, excluding saving | -| Save | Serialization time | -| Index GiB | Serialized files, including sidecars and retained raw vectors | - -Times are seconds. Data preparation, ground-truth generation, and correctness -checks are outside construction timing. - -## Scope and limitations - -Each method has one build or fit per dataset; results do not measure run-to-run -variation. -The committed CSVs support checking the displayed tables and plotted points, -but do not include raw run logs or measurement binaries. The 0.7.1 raw records, -logs and indexes are retained locally under -`results/benchmarks-0.7.1-20261023/`. The source/build manifest, wheel hashes, -validation logs and execution status are retained in the local release task records. -Historical baseline raw logs and -binaries are not included. No run-to-run or shared-host noise correction is -applied. The experiments in the cited papers are separate measurements. +Use `--dry-run` to check prerequisites and print the installation commands +without running them. The installer defaults to the historical GCC 13 toolchain +and builds RaBitQ with `RABITQ_ENABLE_NATIVE_OPTIMIZATION=ON`; it does not +recreate the GCC 14.3.0, native-optimization-OFF wheel used for the 0.7.1 +measurements. Match the recorded compiler and native-optimization setting when +comparing new library timings. ## Data and reproduction Run from the selected checkout's repository root after installation. Use the -same checkout for installation and measurement: +same checkout for installation and measurement. The runners target the recorded +hardware: the download, build and query stages run under +`numactl --cpunodebind=0 --membind=0`, ANN +construction uses 48 threads on socket 0, and the runners stop with an error when +the process spans more than one socket, the socket has fewer CPUs than threads, +or k-means does not see 24 physical cores and 48 logical CPUs. Other hosts +need script changes. ```bash # Prepare data, run all benchmarks, and export results @@ -172,7 +205,8 @@ workspace require additional space. Existing results are skipped. Use a fresh `--output-dir` to repeat measurements; this changes raw result and log paths, while documentation exports retain the -paths above. Partial exports preserve unselected results. +paths above. Partial exports preserve unselected results; figure subtitles name +the CPU recorded by the selected records, and retained rows are not rechecked. Use `--stage build`, `--stage query`, or `--stage export` for one stage. Querying reuses saved indexes; export runs no experiments. `--dry-run` previews commands; @@ -183,27 +217,3 @@ reuses saved indexes; export runs no experiments. `--dry-run` previews commands; [Query CSV](figures/qps-recall-points.csv) · [Indexing CSV](figures/indexing.csv) · [K-means CSV](figures/kmeans.csv) - -## Selected GIST1M configurations - -The comparison uses IVF-RaBitQ/HNSW-RaBitQ at 5 total bits (one sign bit plus four extra bits) -and SymphonyQG at 4 bits. For each GIST1M configuration, the QPS bar selects -the fastest measured setting with Recall@10 >= 90%, without interpolation. -Actual recall and total storage differ; this is neither an exact-recall nor an -equal-memory comparison. The tables below identify the selected CSV rows for the -[comparison figure](figures/homepage-gist1m.svg). - -### GIST1M - -| Configuration | Measured Recall@10 | Selected search setting | -| --- | ---: | --- | -| IVF-RaBitQ · 5-bit | 92.82% | `nprobe=128` | -| HNSW-RaBitQ · 5-bit | 92.70% | `ef=512` | -| SymphonyQG · 4-bit | 90.36% | `ef=512` | - -The figures use the 0.7.1 measurements described above, with one search thread -and one query per call. Each setting has one timing pass. QPS includes Python -call overhead after warm-up and excludes loading. Index size is serialized -storage including sidecars, not resident or peak RAM. -See [IVF-RaBitQ results](ivf.md) and [graph results](graphs.md) for full curves, -construction costs, and additional precision/storage configurations. diff --git a/docs/docs/benchmarks/figures/graphs-dbpedia-qps-recall.svg b/docs/docs/benchmarks/figures/graphs-dbpedia-qps-recall.svg index e205caba..5aa5eb37 100644 --- a/docs/docs/benchmarks/figures/graphs-dbpedia-qps-recall.svg +++ b/docs/docs/benchmarks/figures/graphs-dbpedia-qps-recall.svg @@ -1,12 +1,12 @@ - + - 2026-10-23T01:17:57.465322 + 2026-10-31T23:56:27.226980 image/svg+xml @@ -21,964 +21,935 @@ - +" style="fill: #fcfcfb"/> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +" style="fill: #fcfcfb"/> - - - - - - - - - + + - 0.60 + 0.5 - - - - - - + + - 0.65 + 0.6 - - - - - - + + - 0.70 + 0.7 - - - - - - + + - 0.75 + 0.8 - - - - - - + + - 0.80 + 0.9 - - - - - - + + - 0.85 + 1.0 - + + Recall@10 + + + + - - - - - - + - - 0.90 + + + 200 - + - - - - - - + - - 0.95 + + + 500 - + - + - - - - - - - 1.00 + + + 1K - - Recall@10 (measured) - - - - + - - - - - - - - - + + - - - - 1 - 0 - 3 - - + 2K - + - - - - - - + + - - - - 1 - 0 - 4 - - + 5K - + - - - - - - + - - - - - - + + + 10K - + - - - + - - - - - - + + + 20K - - - - - - + + Queries / second (log) - - - - - - + + + + + + + + + + + + + - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - + + + + + + + + + Float32 vectors + + + + + + + + + + + + + SymphonyQG raw + + + + + + + + - + + Faiss HNSW Flat + + + + + + + + + - - - + - - - - - - + + + 0.5 - + - - - + - - - - - - + + + 0.6 - + - - - + - - - - - - + + + 0.7 - + - - - + - - - - - - + + + 0.8 - + - - - + - - - - - - + + + 0.9 - + - - - + + + + + 1.0 - - Queries / second (log scale) + + Recall@10 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + - - - - - - - - - Query set · full sweep · 1 search thread · one timing pass - - - - DBpedia · Graph indexes | Recall@10 vs QPS - - - One Intel Xeon Gold 6418H CPU · one query per call - - - Faint markers: all measured settings. Lines: per-method Pareto frontier; no fitted or extrapolated points. - - - - - - - - - + + + + + - - - SymphonyQG raw - - - - - - - - + + + + + - - - SymphonyQG 4-bit - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + - - SymphonyQG 8-bit + + + + + + + + + + - - + - +"/> - - + + + + + + + + - - HNSW-RaBitQ 1+4 + + + + + + + + + + - - + + + + + + + + + + + + + + - +" style="stroke: #fcfcfb; stroke-width: 0.8; stroke-linejoin: miter"/> - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 4-5 bits + + + + + + + + + + SymphonyQG 4-bit + + + + + + + + + + + + HNSW-RaBitQ 1+4 + + + + + + + + + Faiss HNSW SQ4 - - HNSW-RaBitQ 1+8 - - - - - + + - - - - +" style="fill: #fcfcfb"/> - - Faiss HNSW Flat + + + + + + + + 0.5 + + + + + + + + + 0.6 + + + + + + + + + 0.7 + + + + + + + + + 0.8 + + + + + + + + + 0.9 + + + + + + + + + 1.0 + + + + Recall@10 + - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - Faiss HNSW SQ4 + + + + + + + + + + - - - - - - - + + + + + + + + + - - Faiss HNSW SQ8 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 8-9 bits + + + + + + + + + + SymphonyQG 8-bit + + + + + + + + + HNSW-RaBitQ 1+8 + + + + + + + + + Faiss HNSW SQ8 + + + DBpedia · Graph indexes + + + Recall@10 vs queries per second · 1 search thread, 1 query per call · CPU: Intel(R) Xeon(R) Gold 6418H + - - + + + + + + + + diff --git a/docs/docs/benchmarks/figures/graphs-gist1m-qps-recall.svg b/docs/docs/benchmarks/figures/graphs-gist1m-qps-recall.svg index a1f952de..a18746e5 100644 --- a/docs/docs/benchmarks/figures/graphs-gist1m-qps-recall.svg +++ b/docs/docs/benchmarks/figures/graphs-gist1m-qps-recall.svg @@ -1,12 +1,12 @@ - + - 2026-10-23T01:17:57.296324 + 2026-10-31T23:56:26.819304 image/svg+xml @@ -21,956 +21,995 @@ - +" style="fill: #fcfcfb"/> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +" style="fill: #fcfcfb"/> - - - - - - - - - + + - 0.3 + 0.4 - - - - - - + + - 0.4 + 0.5 - - - - - - + + - 0.5 + 0.6 - - - - - - + + - 0.6 + 0.7 - - - - - - + + - 0.7 + 0.8 - - - - - - + + - 0.8 + 0.9 - - - - - - + + - 0.9 + 1.0 - - - - - - - - - - - 1.0 - - - - Recall@10 (measured) + + Recall@10 + + + + + + 200 + + + - - - - - - - - - + + - - - - 1 - 0 - 3 - - + 500 - + - - - - - - + + - - - - 1 - 0 - 4 - - + 1K - + - - - - - - + - - - - - - + + + 2K - - - + + + + + 5K - - - - + + + + + + 10K - - - - + + + + + + 20K - - - - + + + + + + 50K - - - - - + + Queries / second (log) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Float32 vectors + + + + + + + + + + + + + SymphonyQG raw + + + + + + + + - - - - - + + Faiss HNSW Flat + + + + + + + + + + + + + + + 0.4 - - - - - + + + + + + + 0.5 - - - - - + + + + + + + 0.6 - - - - - + + + + + + + 0.7 - - - - - + + + + + + + 0.8 - - - - - + + + + + + + 0.9 - - - - - + + + + + + + 1.0 - - - - - + + Recall@10 + + + + + + + - - - - - + + + + - - - - - + + + + - - - - - + + + + - - - - - + + + + - - - - - + + + + - - Queries / second (log scale) + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + - - - - - - - - Query set · full sweep · 1 search thread · one timing pass - - - - GIST1M · Graph indexes | Recall@10 vs QPS - - - One Intel Xeon Gold 6418H CPU · one query per call - - - Faint markers: all measured settings. Lines: per-method Pareto frontier; no fitted or extrapolated points. - - - - - - - - - + + + + + + + + + - - SymphonyQG raw - - - + - +"/> - - + + + + + + + + - - SymphonyQG 4-bit - - - + + + + + + + + + + + + + + + + + + + + + + + + + - +" style="stroke: #fcfcfb; stroke-width: 0.8; stroke-linejoin: miter"/> - - - - - - SymphonyQG 8-bit - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 4-5 bits + + + + + + + + + + SymphonyQG 4-bit + + + + + - - - +" style="stroke: #1baf7a; stroke-linejoin: miter"/> + + + + - - - HNSW-RaBitQ 1+4 - - - - - - - - + + HNSW-RaBitQ 1+4 + + + + + + + + + Faiss HNSW SQ4 - - HNSW-RaBitQ 1+8 - - - - - + + - - - - +" style="fill: #fcfcfb"/> - - Faiss HNSW Flat + + + + + + + + 0.4 + + + + + + + + + 0.5 + + + + + + + + + 0.6 + + + + + + + + + 0.7 + + + + + + + + + 0.8 + + + + + + + + + 0.9 + + + + + + + + + 1.0 + + + + Recall@10 + - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - Faiss HNSW SQ4 + + + + + + + + + + - - - - - - - + + + + + + + + + - - Faiss HNSW SQ8 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 8-9 bits + + + + + + + + + + SymphonyQG 8-bit + + + + + + + + + HNSW-RaBitQ 1+8 + + + + + + + + + Faiss HNSW SQ8 + + + GIST1M · Graph indexes + + + Recall@10 vs queries per second · 1 search thread, 1 query per call · CPU: Intel(R) Xeon(R) Gold 6418H + - - + + + + + + + + diff --git a/docs/docs/benchmarks/figures/ivf-dbpedia-qps-recall.svg b/docs/docs/benchmarks/figures/ivf-dbpedia-qps-recall.svg index 317dc1f9..54277592 100644 --- a/docs/docs/benchmarks/figures/ivf-dbpedia-qps-recall.svg +++ b/docs/docs/benchmarks/figures/ivf-dbpedia-qps-recall.svg @@ -1,12 +1,12 @@ - + - 2026-10-23T01:17:57.383560 + 2026-10-31T23:56:27.022552 image/svg+xml @@ -21,1121 +21,1136 @@ - +" style="fill: #fcfcfb"/> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +" style="fill: #fcfcfb"/> - - - - - - - - - + + - 0.5 + 0.5 - - - - - - + + - 0.6 + 0.6 - - - - - - + + - 0.7 + 0.7 - - - - - - + + - 0.8 + 0.8 - - - - - - + + - 0.9 + 0.9 - - - - - - + + - 1.0 + 1.0 - Recall@10 (measured) + Recall@10 - - - - - - - - - + + - - - - 1 - 0 - 2 - - + 100 - - - - - - + + - - - - 1 - 0 - 3 - - + 200 - - - - - - + - - - - - - + + + 500 - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - - - - - - + + + 1K - - - - - - - - - Queries / second (log scale) + + Queries / second (log) - - + - +"/> - - - - - - - - - - - + + + + + + + + + + + - - + - +"/> - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + - +"/> - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + - +"/> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + - +" style="stroke: #fcfcfb; stroke-width: 0.8"/> - - - - - - - - - - - + + + + + + + + + + + - - + + - +" style="stroke: #fcfcfb; stroke-width: 0.8; stroke-linejoin: miter"/> - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + - +" style="stroke: #fcfcfb; stroke-width: 0.8; stroke-linejoin: miter"/> - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - - + + - +" style="stroke: #fcfcfb; stroke-width: 0.8; stroke-linejoin: miter"/> - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + - + - + - - Query set · full sweep · 1 search thread · one timing pass + + Codes + float32 reranking - - - DBpedia · IVF indexes | Recall@10 vs QPS - - - One Intel Xeon Gold 6418H CPU · one query per call - - - Faint markers: all measured settings. Lines: per-method Pareto frontier; no fitted or extrapolated points. - - - - - - + + + + - - - +" style="stroke: #2a78d6"/> + + + + - - - IVF-RaBitQ raw - - - - - + IVF-RaBitQ raw + + + + + - - - +" style="stroke: #eb6834; stroke-linejoin: miter"/> + + + + + + + Faiss IVF RaBitQfs raw + RFlat + + + + + + + + + + + + Faiss IVF PQfs + RFlat + + + + + + + + + + + + Faiss OPQ-IVF PQfs + RFlat - - IVF-RaBitQ 1+4 - - - - - + + - - - +" style="fill: #fcfcfb"/> + + + + + + + + + 0.5 + + + + + + + + + 0.6 + + + + + + + + + 0.7 + + + + + + + + + 0.8 + + + + + + + + + 0.9 + + + + + + + + + 1.0 + + + + Recall@10 - - IVF-RaBitQ 1+8 + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - + + + + + + + + + + + + - - Faiss IVF RaBitQfs raw + RFlat + + + + + + + + + + + + + - - - - - - - + + + + + + + + + + + + - - Faiss IVF RaBitQfs 1+4 + + + + + + + + + + + + + + - - - - + + + + + + + 1 + 4 bits + + + + + + + + + + IVF-RaBitQ 1+4 + + + + + + + + + Faiss IVF RaBitQfs 1+4 + + + + + + - - - +" style="fill: #fcfcfb"/> + + + + + + + + + 0.5 + + + + + + + + + 0.6 + + + + + + + + + 0.7 + + + + + + + + + 0.8 + + + + + + + + + 0.9 + + + + + + + + + 1.0 + + + + Recall@10 - - Faiss IVF RaBitQfs 1+8 + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - + + + + + + + + + + + + - - Faiss IVF PQfs + RFlat + + + + + + + + + + + + + - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - Faiss OPQ-IVF PQfs + RFlat + + + + + + + 1 + 8 bits + + + + + + + + + + IVF-RaBitQ 1+8 + + + + + + + + + Faiss IVF RaBitQfs 1+8 + + + + + DBpedia · IVF indexes + + + Recall@10 vs queries per second · 1 search thread, 1 query per call · CPU: Intel(R) Xeon(R) Gold 6418H - - + + + + + + + + diff --git a/docs/docs/benchmarks/figures/ivf-gist1m-qps-recall.svg b/docs/docs/benchmarks/figures/ivf-gist1m-qps-recall.svg index 20ebfd75..77a58da3 100644 --- a/docs/docs/benchmarks/figures/ivf-gist1m-qps-recall.svg +++ b/docs/docs/benchmarks/figures/ivf-gist1m-qps-recall.svg @@ -1,12 +1,12 @@ - + - 2026-10-23T01:17:57.116717 + 2026-10-31T23:56:26.599182 image/svg+xml @@ -21,1118 +21,1143 @@ - +" style="fill: #fcfcfb"/> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +" style="fill: #fcfcfb"/> - - - - - - - - - + + - 0.2 + 0.5 - - - - - - + + - 0.4 + 0.6 - - - - - - + + - 0.6 + 0.7 - - - - - - + + - 0.8 + 0.8 - - - - - - + + - 1.0 + 0.9 - - Recall@10 (measured) + + + + + + + 1.0 + + + + Recall@10 - - - - - - - - - - - - - - - - 1 - 0 - 2 - - - - - - - - - - - + + - - - - 1 - 0 - 3 - - + 100 - + - - - - - - + - - - - - - + + + 200 - + - - - + - - - - - - + + + 500 - + - - - + - - - - - - + + + 1K - + - - - + - - - - - - - - - - - - - + + + 2K - - Queries / second (log scale) + + Queries / second (log) - - + - +"/> - - - - - - - - - - - + + + + + + + + + + - - + - +"/> - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + - +"/> - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + - +"/> - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + - +" style="stroke: #fcfcfb; stroke-width: 0.8"/> - - - - - - - - - - - + + + + + + + + + + - - + + - +" style="stroke: #fcfcfb; stroke-width: 0.8; stroke-linejoin: miter"/> - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + - - + + - +" style="stroke: #fcfcfb; stroke-width: 0.8; stroke-linejoin: miter"/> - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + - - + + - +" style="stroke: #fcfcfb; stroke-width: 0.8; stroke-linejoin: miter"/> - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + - + - + - - Query set · full sweep · 1 search thread · one timing pass + + Codes + float32 reranking - - - GIST1M · IVF indexes | Recall@10 vs QPS - - - One Intel Xeon Gold 6418H CPU · one query per call - - - Faint markers: all measured settings. Lines: per-method Pareto frontier; no fitted or extrapolated points. - - - - - - + + + + - - - +" style="stroke: #2a78d6"/> + + + + - - - IVF-RaBitQ raw - - - - - + IVF-RaBitQ raw + + + + + - - - +" style="stroke: #eb6834; stroke-linejoin: miter"/> + + + + + + + Faiss IVF RaBitQfs raw + RFlat + + + + + + + + + + + + Faiss IVF PQfs + RFlat + + + + + + + + + + + + Faiss OPQ-IVF PQfs + RFlat - - IVF-RaBitQ 1+4 - - - - - + + - - - +" style="fill: #fcfcfb"/> + + + + + + + + + 0.5 + + + + + + + + + 0.6 + + + + + + + + + 0.7 + + + + + + + + + 0.8 + + + + + + + + + 0.9 + + + + + + + + + 1.0 + + + + Recall@10 - - IVF-RaBitQ 1+8 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + - - Faiss IVF RaBitQfs raw + RFlat + + + + + + + + + + + + + + - - - - - - - + + + + + + + + + + + + + - - Faiss IVF RaBitQfs 1+4 + + - - - - + + + + 1 + 4 bits + + + + + + + + + + IVF-RaBitQ 1+4 + + + + + + + + + Faiss IVF RaBitQfs 1+4 + + + + + + - - - +" style="fill: #fcfcfb"/> + + + + + + + + + 0.5 + + + + + + + + + 0.6 + + + + + + + + + 0.7 + + + + + + + + + 0.8 + + + + + + + + + 0.9 + + + + + + + + + 1.0 + + + + Recall@10 - - Faiss IVF RaBitQfs 1+8 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - + + + + + + + + + + + + - - Faiss IVF PQfs + RFlat + + + + + + + + + + + + + - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - Faiss OPQ-IVF PQfs + RFlat + + + + + + + + 1 + 8 bits + + + + + + + + + + IVF-RaBitQ 1+8 + + + + + + + + + Faiss IVF RaBitQfs 1+8 + + + GIST1M · IVF indexes + + + Recall@10 vs queries per second · 1 search thread, 1 query per call · CPU: Intel(R) Xeon(R) Gold 6418H + - - + + + + + + + + diff --git a/docs/docs/benchmarks/graphs.md b/docs/docs/benchmarks/graphs.md index 3c365bf7..9d3275bd 100644 --- a/docs/docs/benchmarks/graphs.md +++ b/docs/docs/benchmarks/graphs.md @@ -7,33 +7,10 @@ See the [source revisions, environments and comparison limits](bench.md#package- Settings and results for eight configurations. See the [overview](bench.md) for hardware, datasets, and measurement definitions. -## Index configurations - -SymphonyQG uses maximum degree 32, `ef_construction=200`, `init="pipnn"`, and one -refinement iteration. Both HNSW implementations use `M=16` and -`efConstruction=200`, giving a base-layer degree bound of 32. - -| Method | Stored representation | Additional construction settings | -| --- | --- | --- | -| SymphonyQG raw | `quantization_bits=0`, raw vectors | Degree 32, PIPNN initialization | -| SymphonyQG 4-bit | `quantization_bits=4` | Degree 32, PIPNN initialization | -| SymphonyQG 8-bit | `quantization_bits=8` | Degree 32, PIPNN initialization | -| HNSW-RaBitQ 1+4 | `nbits=5` | `M=16`, seed 42, 16 residual centroids | -| HNSW-RaBitQ 1+8 | `nbits=9` | `M=16`, seed 42, 16 residual centroids | -| Faiss HNSW Flat | `HNSW16` | Float32 vectors | -| Faiss HNSW SQ4 | `HNSW16_SQ4` | Four-bit scalar quantization | -| Faiss HNSW SQ8 | `HNSW16_SQ8` | Eight-bit scalar quantization | - -HNSW-RaBitQ trains 16 centroids with RaBitQKMeans (up to 10 iterations, seed 42, -exact final assignment, spherical training for DBpedia's inner-product metric). -Training is included in construction time. SymphonyQG 4/8-bit and HNSW-RaBitQ -1+4/1+8 use different encodings; serialized sizes include their full storage costs. - -All graph methods sweep `ef=16,32,64,128,256,512,1024` (Faiss calls this -`efSearch`). - ## Reading the results +Each figure groups methods with similar storage per dimension into panels; +method names are defined under [index configurations](#index-configurations). Curves use one search thread and one query per call over all 1,000 queries. Indexes are built with 48 threads and reused for the query sweeps. See [figure definitions](bench.md#reading-the-figures) and @@ -41,7 +18,7 @@ See [figure definitions](bench.md#reading-the-figures) and ## GIST1M -![GIST1M graphs QPS–recall trade-off](figures/graphs-gist1m-qps-recall.svg) +![GIST1M graphs QPS–recall trade-off](figures/graphs-gist1m-qps-recall.svg){ .chart } ### Indexing time and size @@ -60,7 +37,7 @@ See [figure definitions](bench.md#reading-the-figures) and ## DBpedia -![DBpedia graphs QPS–recall trade-off](figures/graphs-dbpedia-qps-recall.svg) +![DBpedia graphs QPS–recall trade-off](figures/graphs-dbpedia-qps-recall.svg){ .chart } ### Indexing time and size @@ -77,6 +54,32 @@ See [figure definitions](bench.md#reading-the-figures) and | Faiss HNSW SQ8 | 1.79 | 80.66 | 82.45 | 0.64 | 1.56 | +## Index configurations + +SymphonyQG uses maximum degree 32, `ef_construction=200`, `init="pipnn"`, and one +refinement iteration. Both HNSW implementations use `M=16` and +`efConstruction=200`, giving a base-layer degree bound of 32. + +| Method | Stored representation | Additional construction settings | +| --- | --- | --- | +| SymphonyQG raw | `quantization_bits=0`, raw vectors | Degree 32, PIPNN initialization | +| SymphonyQG 4-bit | `quantization_bits=4` | Degree 32, PIPNN initialization | +| SymphonyQG 8-bit | `quantization_bits=8` | Degree 32, PIPNN initialization | +| HNSW-RaBitQ 1+4 | `nbits=5` | `M=16`, seed 42, 16 residual centroids | +| HNSW-RaBitQ 1+8 | `nbits=9` | `M=16`, seed 42, 16 residual centroids | +| Faiss HNSW Flat | `HNSW16` | Float32 vectors | +| Faiss HNSW SQ4 | `HNSW16_SQ4` | Four-bit scalar quantization | +| Faiss HNSW SQ8 | `HNSW16_SQ8` | Eight-bit scalar quantization | + +HNSW-RaBitQ trains 16 centroids with RaBitQKMeans (up to 10 iterations, seed 42, +exact final assignment, spherical training for DBpedia's inner-product metric). +Training is reported separately and included in total construction time. +SymphonyQG 4/8-bit and HNSW-RaBitQ 1+4/1+8 use different encodings; serialized +sizes include their full storage costs. + +All graph methods sweep `ef=16,32,64,128,256,512,1024` (Faiss calls this +`efSearch`). + ## Download measurements [QPS–recall CSV](figures/qps-recall-points.csv) · [Indexing CSV](figures/indexing.csv) diff --git a/docs/docs/benchmarks/ivf.md b/docs/docs/benchmarks/ivf.md index 1cff9b2e..51aa6a74 100644 --- a/docs/docs/benchmarks/ivf.md +++ b/docs/docs/benchmarks/ivf.md @@ -7,35 +7,10 @@ See the [source revisions, environments and comparison limits](bench.md#package- Settings and results for eight configurations. See the [overview](bench.md) for hardware, datasets, and measurement definitions. -## Index configurations - -Every IVF index uses **4,096 clusters**. `d` is the dataset dimension and -`m=d/2` for PQ: 480 on GIST1M and 768 on DBpedia. PQ subquantizers -use four bits. RaBitQ clustering uses up to 10 iterations, seed 42, exact final -assignment, and spherical training for DBpedia's inner-product metric. Faiss uses its -factory training defaults. Training is included in construction time. - -| Method | Construction specification | Search parameters | -| --- | --- | --- | -| IVF-RaBitQ raw | `IvfIndex(nbits=32)`; one-bit filter with original float32 vectors | `nprobe` | -| IVF-RaBitQ 1+4 | `IvfIndex(nbits=5)` | `nprobe` | -| IVF-RaBitQ 1+8 | `IvfIndex(nbits=9)` | `nprobe` | -| Faiss IVF RaBitQfs raw + RFlat | `HRd,IVF4096,RaBitQfs1,RFlat` | `nprobe`, `k_factor` | -| Faiss IVF RaBitQfs 1+4 | `HRd,IVF4096,RaBitQfs5` | `nprobe` | -| Faiss IVF RaBitQfs 1+8 | `HRd,IVF4096,RaBitQfs9` | `nprobe` | -| Faiss IVF PQfs + RFlat | `IVF4096,PQ{m}x4fs,RFlat` | `nprobe`, `k_factor` | -| Faiss OPQ-IVF PQfs + RFlat | `OPQ{m},IVF4096,PQ{m}x4fs,RFlat` | `nprobe`, `k_factor` | - -`1+4` and `1+8` denote one sign bit plus four or eight extra bits. -`nbits=32` retains raw float32 vectors in RaBitQ-Library; `RFlat` retains them -for Faiss reranking. Index size and QPS include retained vectors and reranking. - -The search grid is `nprobe=1,2,4,8,16,32,64,128,256,512`, crossed with -`k_factor=1,2,5,10,20` for the `RFlat` methods. Quantized IVF-RaBitQ uses its -default HACC search mode for total bit widths 5 and 9. - ## Reading the results +Each figure groups methods with similar storage per dimension into panels; +method names are defined under [index configurations](#index-configurations). Curves use one search thread and one query per call over all 1,000 queries. Indexes are built with 48 threads and reused for the query sweeps. See [figure definitions](bench.md#reading-the-figures) and @@ -43,7 +18,7 @@ See [figure definitions](bench.md#reading-the-figures) and ## GIST1M -![GIST1M ivf QPS–recall trade-off](figures/ivf-gist1m-qps-recall.svg) +![GIST1M ivf QPS–recall trade-off](figures/ivf-gist1m-qps-recall.svg){ .chart } ### Indexing time and size @@ -62,7 +37,7 @@ See [figure definitions](bench.md#reading-the-figures) and ## DBpedia -![DBpedia ivf QPS–recall trade-off](figures/ivf-dbpedia-qps-recall.svg) +![DBpedia ivf QPS–recall trade-off](figures/ivf-dbpedia-qps-recall.svg){ .chart } ### Indexing time and size @@ -79,6 +54,34 @@ See [figure definitions](bench.md#reading-the-figures) and | Faiss OPQ-IVF PQfs + RFlat | 1414.28 | 13.65 | 1427.93 | 2.73 | 6.14 | +## Index configurations + +Every IVF index uses **4,096 clusters**. `d` is the dataset dimension and +`m=d/2` for PQ: 480 on GIST1M and 768 on DBpedia. PQ subquantizers +use four bits. RaBitQ clustering uses up to 10 iterations, seed 42, exact final +assignment, and spherical training for DBpedia's inner-product metric. Faiss uses its +factory training defaults. Training is reported separately and included in total +construction time. + +| Method | Construction specification | Search parameters | +| --- | --- | --- | +| IVF-RaBitQ raw | `IvfIndex(nbits=32)`; one-bit filter with original float32 vectors | `nprobe` | +| IVF-RaBitQ 1+4 | `IvfIndex(nbits=5)` | `nprobe` | +| IVF-RaBitQ 1+8 | `IvfIndex(nbits=9)` | `nprobe` | +| Faiss IVF RaBitQfs raw + RFlat | `HRd,IVF4096,RaBitQfs1,RFlat` | `nprobe`, `k_factor` | +| Faiss IVF RaBitQfs 1+4 | `HRd,IVF4096,RaBitQfs5` | `nprobe` | +| Faiss IVF RaBitQfs 1+8 | `HRd,IVF4096,RaBitQfs9` | `nprobe` | +| Faiss IVF PQfs + RFlat | `IVF4096,PQ{m}x4fs,RFlat` | `nprobe`, `k_factor` | +| Faiss OPQ-IVF PQfs + RFlat | `OPQ{m},IVF4096,PQ{m}x4fs,RFlat` | `nprobe`, `k_factor` | + +`1+4` and `1+8` denote one sign bit plus four or eight extra bits. +`nbits=32` retains raw float32 vectors in RaBitQ-Library; `RFlat` retains them +for Faiss reranking. Index size and QPS include retained vectors and reranking. + +The search grid is `nprobe=1,2,4,8,16,32,64,128,256,512`, crossed with +`k_factor=1,2,5,10,20` for the `RFlat` methods. Quantized IVF-RaBitQ uses its +default HACC search mode for total bit widths 5 and 9. + ## Download measurements [QPS–recall CSV](figures/qps-recall-points.csv) · [Indexing CSV](figures/indexing.csv) diff --git a/docs/docs/benchmarks/kmeans.md b/docs/docs/benchmarks/kmeans.md index b0b40221..26a4438a 100644 --- a/docs/docs/benchmarks/kmeans.md +++ b/docs/docs/benchmarks/kmeans.md @@ -7,6 +7,30 @@ See the [source revisions, environments and comparison limits](bench.md#package- Compare **QGKMeans**, **Faiss K-means**, and **SuperKMeans** using their Python APIs. Metrics are total time, WCSS, and recall@10 with 1% of clusters probed. +## Results + +### GIST1M + + +| Method | Total time (s) | WCSS | Recall@10 (40 probes) | Iterations | +| --- | ---: | ---: | ---: | ---: | +| QGKMeans | 14.88 | 1034709 | 0.815900 | 25 | +| Faiss K-means | 84.17 | 1034624.88 | 0.815700 | 25 | +| SuperKMeans | 28.14 | 1034648.39 | 0.820700 | 25 | + + +### DBpedia + + +| Method | Total time (s) | WCSS | Recall@10 (40 probes) | Iterations | +| --- | ---: | ---: | ---: | ---: | +| QGKMeans | 22.35 | 620213.444 | 0.894400 | 25 | +| Faiss K-means | 171.03 | 620737.534 | 0.894700 | 25 | +| SuperKMeans | 35.23 | 620268.628 | 0.896800 | 24 | + + +[Download measurements (CSV)](figures/kmeans.csv) + ## Settings | Setting | Value | @@ -49,30 +73,6 @@ and initializers; the same seed does not guarantee identical starting centroids. Exact assignments are checked on 128 base vectors using float64 distances, outside timing. -## Results - -### GIST1M - - -| Method | Total time (s) | WCSS | Recall@10 (40 probes) | Iterations | -| --- | ---: | ---: | ---: | ---: | -| QGKMeans | 14.88 | 1034709 | 0.815900 | 25 | -| Faiss K-means | 84.17 | 1034624.88 | 0.815700 | 25 | -| SuperKMeans | 28.14 | 1034648.39 | 0.820700 | 25 | - - -### DBpedia - - -| Method | Total time (s) | WCSS | Recall@10 (40 probes) | Iterations | -| --- | ---: | ---: | ---: | ---: | -| QGKMeans | 22.35 | 620213.444 | 0.894400 | 25 | -| Faiss K-means | 171.03 | 620737.534 | 0.894700 | 25 | -| SuperKMeans | 35.23 | 620268.628 | 0.896800 | 24 | - - -[Download measurements (CSV)](figures/kmeans.csv) - ## Reproduction Follow the [installation instructions](bench.md#package-installation), then run: diff --git a/docs/docs/cpp_integration.md b/docs/docs/cpp_integration.md index ec135f53..03d70b5a 100644 --- a/docs/docs/cpp_integration.md +++ b/docs/docs/cpp_integration.md @@ -211,7 +211,7 @@ No ANN index is constructed. cmake -S sample/cpp/core -B build-core-example \ -DCMAKE_PREFIX_PATH="$HOME/.local/rabitqlib-core" cmake --build build-core-example -ctest --test-dir build-core-example --output-on-failure +ctest --no-tests=error --test-dir build-core-example --output-on-failure ``` With project samples enabled, the same program is built as `core_example` under @@ -249,7 +249,7 @@ MODULE, not a C++ link library, and is not exported as a C++ `find_package` comp ```bash cmake -S . -B build -DRABITQ_BUILD_TESTS=ON -DCMAKE_BUILD_TYPE=Release -DRABITQ_ENABLE_NATIVE_OPTIMIZATION=OFF cmake --build build --parallel -ctest --test-dir build --output-on-failure +ctest --no-tests=error --test-dir build --output-on-failure ``` GoogleTest is downloaded during test configuration. diff --git a/docs/docs/index.md b/docs/docs/index.md index 538a6f2c..194eeb6a 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -13,8 +13,10 @@ hide: # Vector search with
compact RaBitQ codes. { #compact-vector-search-with-rabitq }

Compress vectors and search for nearest neighbors. -Use ready-made indexes in Python or C++, or integrate quantization and distance -estimation into your own C++ search engine.

+RaBitQ estimates distances from 1–9-bit codes with a theoretical error bound, +and its multi-bit extension is asymptotically optimal. Use ready-made indexes in +Python or C++, or integrate quantization and distance +estimation into your own C++ search engine.

@@ -48,6 +50,8 @@ of clusters probed to balance storage, recall, and query time. **Updates:** append and remove vectors. +**GIST1M, 5-bit:** 1,038 QPS at 92.8% Recall@10; 0.60 GiB index. +
@@ -59,6 +63,8 @@ search window and graph degree alongside the code precision. **Updates:** append and remove vectors. +**GIST1M, 5-bit:** 1,866 QPS at 92.7% Recall@10; 0.72 GiB index. +
@@ -70,6 +76,8 @@ Combine batched neighbor scoring with raw or 4/8-bit vector storage. **Updates:** rebuild to change the dataset. +**GIST1M, 4-bit:** 3,086 QPS at 90.4% Recall@10; 4.39 GiB index. +
@@ -87,9 +95,11 @@ For input shapes, distance conventions, memory estimates, and threading, see ## Compare measured performance -See the [benchmarks](benchmarks/bench.md) for datasets, settings, measured -results, and limitations. Evaluate recall and query time on your own dataset -when choosing an index. +The GIST1M numbers above are the fastest measured setting with Recall@10 ≥ 90%, +using one search thread and one query per call with RaBitQ-Library 0.7.1; index +size is serialized storage. See the [benchmark results](benchmarks/bench.md#key-results) +for datasets, baselines, settings, and limitations. Evaluate recall and query time +on your own dataset when choosing an index. ## Built with RaBitQ-Library diff --git a/docs/docs/index/hnsw.md b/docs/docs/index/hnsw.md index dd1c365f..ac91f5de 100644 --- a/docs/docs/index/hnsw.md +++ b/docs/docs/index/hnsw.md @@ -60,7 +60,7 @@ see [C++ integration](../cpp_integration.md). | `dim` | Original input dimension; Python supports 64–65,536 and pads internally. | | `max_elements` | Capacity; build accepts 1 through this many rows. Reserve extra capacity or call `resize` before adding beyond it. | | `nbits` / C++ `total_bits` | 1–9 total bits including the sign bit; Python defaults to 8. Raw-vector storage is not supported. | -| `M`, `ef_construction` | Graph connectivity and construction search budget; Python defaults are 16 and 200. | +| `M`, `ef_construction` | Graph connectivity and construction search budget; Python defaults are 16 and 200. `M` must be at least 2 and is capped at 10,000; `ef_construction` is raised to at least `M`. | | `metric`, `random_seed` | Python defaults are `"l2"` and 100; `"ip"` is also supported. | | `k`, `ef` | Positive result count and search window. Python requires `k <= max_elements`; omitted/zero `ef` uses `max(k, 10)`, positive `ef` is raised to at least `k`, and `ef` above the stored point count is clamped to it. | @@ -185,9 +185,9 @@ retrying, or rebuild to restore full reachability. See the shared [index count contract](usage.md#index-counts). Removed records still occupy capacity. -`add` never grows the index: it throws once `num_stored_points()` reaches -`max_elements()`, and `resize` is the only way to raise that. Reserving capacity -avoids reallocating and copying the whole base-layer record storage during `add`. +`add` never grows the index: it rejects a whole batch whose size exceeds +`max_elements() - num_stored_points()`, and `resize` is the only way to raise +that. Reserving capacity avoids reallocating and copying the whole base-layer record storage during `add`. It does not make insertion allocation-free: batch scratch, upper-layer link lists, and label-map entries still require allocations, and the label map may rehash. @@ -233,10 +233,12 @@ exclusive access as other updates. Removed points still take slots in the `ef_search` candidate set, so a search with `ef_search` close to `k` finds fewer live points as removals accumulate. C++ search -uses `max(ef_search, k)`. Python uses `max(k, 10)` when `ef` is omitted or zero; -an explicit positive `ef` is also raised to at least `k`. +uses `max(ef_search, k)`, clamped to `num_stored_points()`. Python uses `max(k, 10)` +when `ef` is omitted or zero; an explicit positive `ef` is also raised to at least `k`, +and both are clamped to the stored point count. To keep about the same number of live candidates as before the removals, scale -the effective window by the share of live points: +the effective window by the share of live points (search clamps the result to +`num_stored_points()`): ```text max(ef_search, k) * num_stored_points() / (num_stored_points() - removed points) @@ -254,6 +256,14 @@ every reader of the cluster ID masks it off. The mark lives in the base layer, s it survives `save`, `load`, and `resize`, and a file without removals is unchanged. +In C++, `cluster_id_of(internal_id)` returns a stored record's cluster with the +mark masked off; removed points keep their cluster. It takes an internal ID from 0 +through `num_stored_points() - 1`, which matches the label only for points added +in order. It throws `std::logic_error` on a default-constructed index that has +not been loaded, and `std::invalid_argument` for an ID not below +`num_stored_points()`, including any ID before `construct`. Python does not +expose it. + A file that has removals cannot be read by earlier releases. Releases 0.3.7 through 0.5.1 check every cluster ID on `load` and reject the file as invalid instead of returning removed points. Releases before 0.3.7 do not check cluster @@ -280,7 +290,7 @@ Each indexed element is stored in the following layout: ``` [number of edges] [edges] -[cluster ID] (its high bit marks a removed point, see Removing points) +[cluster ID] (its high bit marks a removed point, see How removal is stored) [external label] [BinData (1-bit * padded_dim + factors)] [ExData (ex-bits * padded_dim + factors)] @@ -306,7 +316,8 @@ In the upper layers of HNSW-RaBitQ, we compute the 1-bit estimated distance (usi In the base layer, we apply an adaptive re-ranking strategy: - **search_pool**: A distance-sorted exploration window with capacity - `max(ef_search, k)`, containing both expanded and unexpanded candidates. + `min(max(ef_search, k), num_stored_points())`, containing both expanded and + unexpanded candidates. - **nearest_candidates**: Current best k candidates. Repeat while the exploration window has an unexpanded candidate: diff --git a/docs/docs/index/ivf.md b/docs/docs/index/ivf.md index f196cf40..82775916 100644 --- a/docs/docs/index/ivf.md +++ b/docs/docs/index/ivf.md @@ -2,7 +2,7 @@ C++ callers include `rabitqlib/index/ivf/ivf.hpp` and link `rabitqlib::index`. -[IVF](https://dl.acm.org/doi/10.1109/TPAMI.2010.57) is a classical +[IVF](https://doi.org/10.1109/TPAMI.2010.57) is a classical clustering-based ANN method. IVF-RaBitQ stores RaBitQ codes and vector IDs, optionally retaining raw vectors for reranking, and uses [FastScan](https://dl.acm.org/doi/abs/10.1145/3078971.3078992) to estimate a @@ -51,7 +51,7 @@ See [C++ integration](../cpp_integration.md) for build setup and | `nbits` / C++ `total_bits` | 1–9 total bits including the sign bit, or 32 for owned raw-vector reranking. | | `metric` | Python `"l2"` or `"ip"`; defaults to `"l2"`. | | `initializer` | Defaults to `"auto"`; see [centroid routing](#centroid-routing). | -| `k`, `nprobe` | Positive result count and cluster probe count; Python requires `k <= max_elements`. | +| `k`, `nprobe` | Positive result count and cluster probe count; C++ and Python both require `k` not to exceed `max_elements`, which `add` increases. | | `high_accuracy` | Python defaults to `None`: HACC for 4–9 bits, standard FastScan otherwise. | Results contain IDs and squared L2 or `1 - dot` distances, ordered from smallest diff --git a/docs/docs/installation.md b/docs/docs/installation.md index f8e9e572..61c81bed 100644 --- a/docs/docs/installation.md +++ b/docs/docs/installation.md @@ -46,8 +46,9 @@ python -m pip install "rabitqlib>=0.7.2" For changes from 0.6.0, see the [0.7.0 upgrade notes](releases/0.7.0.md). Wheels target the platforms above and require no compiler or CMake. -Linux ARM64 and macOS ARM64 wheels bundle OpenMP, so wheel users do not -need a separate OpenMP installation. +Every wheel bundles its OpenMP runtime (GNU OpenMP on Linux, LLVM OpenMP on +macOS, and the MSVC `vcomp140` runtime on Windows), so wheel users do not need a +separate OpenMP installation. Release wheels disable native CPU tuning and select supported kernels at runtime. ### Python editor support diff --git a/docs/docs/rabitq/core_api.md b/docs/docs/rabitq/core_api.md index a72e5084..9de9cb9f 100644 --- a/docs/docs/rabitq/core_api.md +++ b/docs/docs/rabitq/core_api.md @@ -260,7 +260,7 @@ it is not a representative random-rotation quality benchmark. cmake -S sample/cpp/stable_core -B build-stable-example \ -DCMAKE_PREFIX_PATH="" cmake --build build-stable-example -ctest --test-dir build-stable-example --output-on-failure +ctest --no-tests=error --test-dir build-stable-example --output-on-failure ``` The frozen v1 consumer (`tests/fixtures/core_api_v1/consumer.cpp`) is compiled diff --git a/docs/docs/rabitq/quantizer.md b/docs/docs/rabitq/quantizer.md index 03e7fe9f..59e4593e 100644 --- a/docs/docs/rabitq/quantizer.md +++ b/docs/docs/rabitq/quantizer.md @@ -171,11 +171,15 @@ int main() { } ``` -The advanced encoders validate their preconditions and throw -`std::invalid_argument` otherwise: `dim` must be at least 2 (the error factor -divides by `dim - 1`), the code element type must hold `bits` (nine bits need -`uint16_t`), inputs must be finite, and `SplitSingleQuery` needs a padded -dimension that is a positive multiple of 32. +The advanced encoders check a few preconditions and throw +`std::invalid_argument` when they fail: encoders that produce error factors +require `dim` of at least 2 (the error factor divides by `dim - 1`), +`quantize_full_single` requires a code element type that holds `bits` (nine +bits need `uint16_t`), and `SplitSingleQuery` needs a padded dimension that is +a positive multiple of 32. `quantize_scalar` does not check the code element +type, so a type that is too narrow silently produces wrong codes. None of the +per-vector encoders checks that inputs are finite; validate each batch before +encoding, as the stable API and the indexes do. #### Querying diff --git a/docs/docs/releases/0.7.1.md b/docs/docs/releases/0.7.1.md new file mode 100644 index 00000000..69aed868 --- /dev/null +++ b/docs/docs/releases/0.7.1.md @@ -0,0 +1,55 @@ +# RaBitQ Library 0.7.1 + +This release lowers the cost of adding vectors to IVF-RaBitQ, makes the +HNSW-RaBitQ rotation follow `random_seed`, and fixes parallel execution and +zero-range query handling. + +## IVF-RaBitQ updates + +- Each cluster now has its own storage, so `add` copies only the clusters it + writes to; its cost grows with those clusters rather than the whole index. + See [cost of `add`](../index/ivf.md#cost-of-add). +- `IvfIndex.reserve(cluster_id, num_points)` (C++ `IVF::reserve`) preallocates + room for a cluster so later `add` calls write into it in place. Reserved room + is not saved. +- A failed `add` still leaves the index unchanged. +- The file format is unchanged: files written with the earlier shared-buffer + layout load and save back to identical bytes. + +## HNSW-RaBitQ seeding + +`random_seed` now also seeds the FHT/Kac rotation (using its low 32 bits), so +single-threaded construction with the same inputs and seed is reproducible within +the same build environment; parallel insertion order can still vary. Loading a +saved index restores its stored rotation. See +[parameters and limits](../index/hnsw.md#parameters-and-limits). + +## Fixes + +- Parallel construction of IVF-RaBitQ's `hnsw` centroid routing no longer races: + insertion levels are drawn under the registration lock and the entry point is + read under the global lock. Concurrent routing searches pass their own `ef` + instead of serializing on a shared mutex. +- Queries whose quantization range is zero produce zero codes on the scalar, + AVX2, AVX-512 and NEON backends, keeping estimated distances finite. +- Python copies NumPy arrays whose data is not aligned for float32 into aligned + storage before native processing; the input arrays are unchanged. + +## Other changes + +- SymphonyQG construction fills under-filled neighbor lists from final + reverse-edge candidates only, using reusable per-worker buffers instead of a + global per-vertex candidate array, which can lower peak construction memory. + Because search-pruned edges no longer take part, graphs differ from 0.7.0 builds. +- Top-level HNSW-RaBitQ query batches reuse OpenMP workers, and PiPNN sizes its + tiles and teams by the available work. +- The published [benchmarks](../benchmarks/bench.md) were refreshed with 0.7.1. + +## Upgrading + +From 0.7.0, existing public call forms and index file formats remain supported; +no index conversion is required. Rebuild C++ consumers with matching headers and +libraries; no cross-version C++ binary ABI is promised. + +When upgrading from before 0.7.0, also follow the +[0.7.0 migration guide](../modular_migration.md). diff --git a/docs/docs/stylesheets/extra.css b/docs/docs/stylesheets/extra.css index db9c52d6..963f64c6 100644 --- a/docs/docs/stylesheets/extra.css +++ b/docs/docs/stylesheets/extra.css @@ -71,12 +71,15 @@ body { -webkit-font-smoothing: antialiased; } .md-header__button.md-logo { color: var(--md-typeset-a-color); } .md-grid { max-width: 68rem; } .md-main__inner { margin-top: 1.6rem; } +/* Tables, code and figures use the full column; prose keeps a readable measure. */ .md-content__inner { box-sizing: border-box; - max-width: 34rem; + max-width: 44rem; margin-inline: auto; padding-inline: 1rem; } +.md-typeset > :is(p, ul, ol, dl, blockquote, h2, h3, h4) { max-width: 34rem; } +.md-typeset > p:has(> img.chart) { max-width: none; } .md-content__inner:has(.hero) { max-width: none; } .hero .hero-logo { display: block; @@ -150,6 +153,12 @@ body { -webkit-font-smoothing: antialiased; } border-left: 2px solid var(--rq-line); } .md-typeset :is(.admonition, details) { border-radius: .4rem; box-shadow: none; } +.md-typeset img.chart { + display: block; + width: 100%; + border: 1px solid var(--rq-line); + border-radius: .4rem; +} .md-typeset .md-button { padding: .55em 1.3em; @@ -208,6 +217,7 @@ body { -webkit-font-smoothing: antialiased; } font-size: .9rem; line-height: 1.65; } +.hero .nowrap { white-space: nowrap; } .hero .hero-actions { display: flex; flex-wrap: wrap; @@ -287,6 +297,9 @@ body { -webkit-font-smoothing: antialiased; } .hero h1 { font-size: clamp(1.65rem, 7.5vw, 2.4rem); } .hero .hero-description { font-size: .86rem; } .feature-grid { grid-template-columns: 1fr; gap: 1rem; } + /* Keep chart text legible; the figure scrolls sideways instead of shrinking. */ + .md-typeset > p:has(> img.chart) { overflow-x: auto; } + .md-typeset img.chart { width: 36rem; max-width: none; } .md-typeset pre > code, .md-typeset .highlight pre > code { padding: .8rem; } } diff --git a/docs/maintaining.md b/docs/maintaining.md index 01053a16..848d6eef 100644 --- a/docs/maintaining.md +++ b/docs/maintaining.md @@ -31,8 +31,9 @@ only the affected job groups: | `tests/python/conftest.py` | Python lint, C++ checks, and Python builds/wheels | | `sample/python/` | Python lint, source-install smoke tests, comparison tests, and wheels | | Python automation under `.github/scripts/` | Python lint (CI routing scripts trigger broader checks) | -| CMake configuration | C++ tests and clang-tidy, plus Python builds/wheels | -| `pyproject.toml` | Python lint, builds, and wheels; no C++ checks | +| Root `CMakeLists.txt` or `cmake/` | C++ tests and clang-tidy, the C++ benchmark build, plus Python builds/wheels | +| `tests/CMakeLists.txt` | C++ platform tests and the core/FastScan consumer matrix; no formatting, clang-tidy or Python builds | +| `pyproject.toml` | Python lint, builds, wheels, and the C++ benchmark build; no other C++ checks | | Shell scripts | ShellCheck and any check driven by the changed script | See [CI coverage](../tests/README.md#ci-coverage) for full-suite versus smoke-test @@ -49,8 +50,10 @@ Pushes compare the complete pushed range; pull requests compare against their base. Renames and deletions are included. Relevant jobs also run if change detection fails, so a detection error cannot silently waive a check. -Every Linux and macOS job that compiles the library with native tuning disabled uses -`ccache` through CMake's compiler launcher, set up by `.github/actions/setup-ccache`. +Every Linux and macOS build job in the `Test` workflow that compiles the library with +native tuning disabled uses `ccache` through CMake's compiler launcher, set up by +`.github/actions/setup-ccache`. The C++ static analysis job and the wheel builds do not +use it. Every run restores the newest cache saved from `main` for its per-configuration key, so unchanged translation units are not recompiled. Only pushes to `main` save, in the job's final `.github/actions/save-ccache` step, so pull requests cannot evict that @@ -115,10 +118,12 @@ every SIMD backend executing. ## Documentation and concurrency gates -The documentation workflow runs on every PR and main push. Its changed-file +The documentation workflow runs on every PR and main push. On PRs, its changed-file output selects the strict MkDocs build for documentation changes; manual runs -also build. `Documentation validation` always reports a result, accepting a -skipped build only when change detection succeeded and did not select docs. +also build. Every main push builds and deploys, so a run cancelled by a newer +push cannot leave the site behind main. `Documentation validation` always +reports a result, accepting a skipped build only when change detection succeeded +and did not select docs. Selected build failures, cancellations, and unexpected skips fail this summary. Only successful push builds can deploy the documentation. @@ -167,7 +172,9 @@ and record results and any unresolved failures or missing coverage in the releas or issue. These manual checks are not enforced by the automatic publishing workflow. Update the stable `X.Y.Z` version in both `pyproject.toml` and `CMakeLists.txt`, -add a short README news entry, and merge into `main`. Once the `Test` and +add a short README news entry and, for releases with user-facing notes, a +`docs/docs/releases/X.Y.Z.md` page listed in the `docs/mkdocs.yml` navigation, +and merge into `main`. Once the `Test` and `Python Wheel` workflows succeed for the same commit, `Release wheels` checks whether that version is newer than the existing release tags. It then builds and tests the release wheels, pushes `vX.Y.Z` at that tested commit, publishes @@ -188,11 +195,14 @@ partial release, rerun the failed release jobs: existing tags must still point to the tested commit, and already uploaded PyPI files are skipped. From the repository root, using your activated project Python environment, -validate automation changes locally with the command below. The workflow contract +validate automation changes locally with the commands below. The workflow contract tests parse workflows with PyYAML, which the Python quality job installs alongside -Ruff; install it in your environment if it is missing. +Ruff; install it in your environment if it is missing. The label classifier tests +also need Node.js on `PATH`. ```bash +ruff format --check .github/scripts +ruff check .github/scripts python -m unittest discover -s .github/scripts -p 'test_*.py' ``` @@ -213,7 +223,7 @@ PR title when a PR is opened, edited, reopened, or marked ready for review: | `deps:` | `release:dependencies` | Dependencies | | `chore:`, `refactor:`, `test:`, `style:` | `release:maintenance` | Maintenance | -Use lowercase prefixes and a nonempty summary, for example +Use lowercase prefixes, a space after the colon and a nonempty summary, for example `fix(ivf): update only affected clusters`. A scope and a `!` marker before the colon are optional. The marker does not change the category or authorize a breaking change; compatibility decisions still follow the project rules. diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index ac225b80..dcb840a1 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -13,6 +13,7 @@ nav: - C++ Integration: cpp_integration.md - Release notes: - "0.7.2": releases/0.7.2.md + - "0.7.1": releases/0.7.1.md - "0.7.0": releases/0.7.0.md - "0.6.0": releases/0.6.0.md - RaBitQ: diff --git a/sample/run_cpp.sh b/sample/run_cpp.sh index c1619b80..a6125a01 100755 --- a/sample/run_cpp.sh +++ b/sample/run_cpp.sh @@ -13,7 +13,7 @@ mkdir -p ./data/gist if [ ! -f ./data/gist/gist.tar.gz ]; then wget -P ./data/gist ftp://ftp.irisa.fr/local/texmex/corpus/gist.tar.gz fi -tar -xzvf ./data/gist/gist.tar.gz -C ./data/gist +tar -xzvf ./data/gist/gist.tar.gz -C ./data # indexing and querying for symqg ./bin/symqg_indexing ./data/gist/gist_base.fvecs 32 400 ./data/gist/symqg_32.index diff --git a/tests/README.md b/tests/README.md index 6793de65..f65c97a4 100644 --- a/tests/README.md +++ b/tests/README.md @@ -14,8 +14,8 @@ and rejection of truncated or unsupported formats. for the Visual Studio 2026 generator) - A C++17 compiler with OpenMP support (GCC, Clang, or Visual Studio 2026) - An x86-64 CPU with AVX2 and FMA (AVX-512 optional), or an ARM64 CPU on Linux or macOS -- Git and network access during the first configuration so CMake can download - GoogleTest 1.14.0 +- Network access during the first configuration so CMake can download the + GoogleTest 1.14.0 source archive Linux CI additionally selects Clang 23 explicitly for portable builds, full C++ tests, installed consumers, and core/FastScan integrations with OpenMP ON/OFF. @@ -47,7 +47,7 @@ exercise the portable runtime-dispatch build. ```powershell cmake -S . -B build -G "Visual Studio 18 2026" -A x64 -DRABITQ_BUILD_TESTS=ON -DRABITQ_ENABLE_NATIVE_OPTIMIZATION=OFF cmake --build build --config Release --parallel -ctest --test-dir build -C Release --output-on-failure +ctest --no-tests=error --test-dir build -C Release --output-on-failure ``` #### Linux @@ -55,7 +55,7 @@ ctest --test-dir build -C Release --output-on-failure ```bash cmake -S . -B build -DRABITQ_BUILD_TESTS=ON -DRABITQ_ENABLE_NATIVE_OPTIMIZATION=OFF -DCMAKE_BUILD_TYPE=Release cmake --build build --parallel -ctest --test-dir build --output-on-failure +ctest --no-tests=error --test-dir build --output-on-failure ``` #### Linux ARM64 @@ -69,11 +69,12 @@ cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \ -DRABITQ_BUILD_TESTS=ON -DRABITQ_BUILD_SAMPLES=OFF \ -DRABITQ_ENABLE_NATIVE_OPTIMIZATION=OFF cmake --build build --parallel 2 -OMP_NUM_THREADS=2 ctest --test-dir build --output-on-failure +OMP_NUM_THREADS=2 ctest --no-tests=error --test-dir build --output-on-failure ``` Native Linux ARM64 CI runs the C++ suite and installed CMake consumer. Its wheel -job builds and tests repaired CPython 3.11–3.14 wheels. Set +job builds and tests repaired CPython 3.11–3.14 wheels (3.11 and 3.14 on pull +requests). Set `RABITQ_TEST_WHEEL=1` after installing a repaired wheel locally to check the AArch64 extension and bundled OpenMP runtime. AVX backend tests skip on ARM. @@ -89,7 +90,7 @@ cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \ -DRABITQ_ENABLE_NATIVE_OPTIMIZATION=OFF \ -DOpenMP_ROOT="$(brew --prefix libomp)" cmake --build build --parallel -OMP_NUM_THREADS=2 ctest --test-dir build --output-on-failure +OMP_NUM_THREADS=2 ctest --no-tests=error --test-dir build --output-on-failure ``` An environment providing `llvm-openmp` can use its prefix as `OpenMP_ROOT` instead. @@ -98,7 +99,8 @@ NEON kernels; explicit AVX backend tests are guarded or skipped. For Python source builds, pass `-Ccmake.define.OpenMP_ROOT=` as well as the native-optimization setting below. Release CI uses native `macos-15` runners, -builds CPython 3.11–3.14 ARM64 wheels, bundles OpenMP with delocate, then installs and +builds CPython 3.11–3.14 ARM64 wheels (3.11 and 3.14 on pull requests), bundles +OpenMP with delocate, then installs and runs the core Python suite for each wheel (excluding the separately tested FAISS comparison and C++ clustering examples). The minimum wheel OS target is macOS 14. The wheel build compiles Homebrew's OpenMP source with that deployment target via @@ -110,7 +112,7 @@ ARM64 extension and bundled OpenMP loader path. The combined executable is `build/tests/Release/rabitq_tests.exe` on Windows and `build/tests/rabitq_tests` on Linux/macOS. To run a subset, add `-R ` to -the CTest command. +the CTest command; keep `--no-tests=error` so a pattern that matches nothing fails. AVX-512 tests skip when the CPU or OS lacks the required features. Tests using `/dev/full` skip on Windows; native POSIX path tests compile only on their @@ -184,7 +186,7 @@ Use an absolute path for ``: cmake --install build --config Release --prefix "" cmake -S tests/consumer -B build-consumer "-DCMAKE_PREFIX_PATH=" cmake --build build-consumer --config Release -ctest --test-dir build-consumer -C Release --output-on-failure +ctest --no-tests=error --test-dir build-consumer -C Release --output-on-failure ``` On Windows, add `-G "Visual Studio 18 2026" -A x64` to the consumer configure command. @@ -202,7 +204,7 @@ Test source-tree consumption through `add_subdirectory` with the same consumer: ```text cmake -S tests/consumer -B build-source-consumer -DCMAKE_BUILD_TYPE=Release -DRABITQ_SOURCE_DIR="" -DRABITQ_ENABLE_NATIVE_OPTIMIZATION=ON cmake --build build-source-consumer --config Release -ctest --test-dir build-source-consumer -C Release --output-on-failure +ctest --no-tests=error --test-dir build-source-consumer -C Release --output-on-failure ``` CI checks both source-tree and installed native builds, portable installations, @@ -233,8 +235,9 @@ have already run in the matching full build. CI uses this for OpenMP ON; OpenMP keeps one combined reference run per compiler/platform. Pass `-DRABITQ_INTEGRATIONS=PACKAGE` to test only the relocated installed package when another job owns the `add_subdirectory` and FetchContent integrations; the default -`ALL` runs all three. Consumer tests, header/symbol checks, invalid-configuration checks -and the core benchmark always remain enabled. +`ALL` runs all three. Consumer tests and header/symbol checks always remain enabled. +Invalid-configuration checks and the core benchmark run when the selected components +include core; `-DRABITQ_COMPONENT=fastscan` skips them. On macOS, also pass `-DOpenMP_ROOT=`. On Windows, set `CMAKE_GENERATOR=Visual Studio 18 2026` and `CMAKE_GENERATOR_PLATFORM=x64` in the @@ -285,11 +288,18 @@ See [the fixture policy](fixtures/core_api_v1/README.md). - `unit/`: C++ index, quantization, FastScan, and utility tests - `common/`: shared C++ test utilities; use `rabitq_test::TempFile` for files a test writes so they are removed even after a failed assertion +- `allocation/`, `dispatch/`, `failures/`, `integration/`: separate C++ executables for + no-allocation, dispatch/static-initialization, allocation-failure and CLI-index checks +- `fixtures/`: frozen core API and persistence compatibility fixtures - `python/`: Python binding tests and persistence fixtures +- `test_mypy.py`, `test_pyright.py`: static type checks of the installed package - `consumer/`: installed CMake package test +- `core_consumer/`: external core/FastScan consumer runner and the core static-initialization test -CMake discovers `*_test.cpp` under `unit/`. Python and -consumer tests run separately using the commands above. +CMake discovers `*_test.cpp` under `unit/` and registers the allocation, dispatch and +failure executables explicitly; `integration/cpp_example_index_check` is built with the +samples for the Python clustering-example tests. Python and consumer tests run +separately using the commands above. ## Coverage ownership and test size @@ -341,9 +351,9 @@ python -m pytest tests/python/test_editor_support.py tests/test_mypy.py -q The suite checks installed declarations, signatures, documentation, examples, and runtime metadata on every wheel. Strict mypy valid/invalid-call checks live in -`tests/test_mypy.py`, run for source installs and each CPython ABI on Linux x86-64 -wheels. Other wheel platforms do not repeat static analysis. CI requires mypy -for these selected checks; missing mypy fails the test. Keep its pins in the +`tests/test_mypy.py`, run for source installs on pushes and for each Linux x86-64 +wheel ABI that CI builds (3.11 and 3.14 on pull requests). Other wheel platforms +do not repeat static analysis. CI requires mypy for these selected checks; missing mypy fails the test. Keep its pins in the `test` extra and Linux x86-64 wheel override synchronized. Benchmark protocol tests run in Python quality CI, separately from wheel tests. diff --git a/tests/python/test_benchmark_protocol.py b/tests/python/test_benchmark_protocol.py index 18792270..1a1df4df 100644 --- a/tests/python/test_benchmark_protocol.py +++ b/tests/python/test_benchmark_protocol.py @@ -186,3 +186,62 @@ def test_kmeans_export_rejects_wrong_metric_before_writing(tmp_path): with pytest.raises(ValueError, match="unexpected benchmark configuration"): publish(tmp_path / "results", docs, datasets=["gist1m"], methods=["faiss"]) assert page.read_text() == "original content" + + +def _write_sweep(directory, name, cpu): + (directory / "gist1m").mkdir(parents=True, exist_ok=True) + (directory / "sweeps" / "gist1m").mkdir(parents=True, exist_ok=True) + record = { + "config": {"query_count": 10}, + "build": {"cpu_placement": {"allowed_cpus": [0]}}, + "hardware": {"cpu": cpu}, + } + (directory / "gist1m" / f"{name}.json").write_text(json.dumps(record)) + sweep = { + "status": "single_query_set", + "dataset": "gist1m", + "query_count": 10, + "query_points": [ + {"setting": {"nprobe": 1}, "recall": 0.9, "one_query_service_s": 0.01} + ], + "thread_environment": dict.fromkeys( + ("OMP_NUM_THREADS", "MKL_NUM_THREADS", "OPENBLAS_NUM_THREADS"), "1" + ), + "cpu_affinity": [0], + } + (directory / "sweeps" / "gist1m" / f"{name}.json").write_text(json.dumps(sweep)) + + +@pytest.mark.parametrize("cpus", [("CPU A", "CPU A"), ("CPU A", "CPU B")]) +def test_plot_subtitle_uses_the_recorded_cpu(tmp_path, monkeypatch, cpus): + monkeypatch.setenv("MPLCONFIGDIR", str(tmp_path)) + pytest.importorskip("matplotlib") + plot_results = importlib.import_module("benchmarks.plot_results") + monkeypatch.setattr(plot_results, "validate_record", lambda *_: None) + monkeypatch.setattr(plot_results, "settings", lambda _: [{"nprobe": 1}]) + methods = ("rabitq-ivf-1p4", "faiss-rabitqfs-1p4") + for name, cpu in zip(methods, cpus): + _write_sweep(tmp_path / "in", name, cpu) + output = tmp_path / "out" + monkeypatch.setattr( + sys, + "argv", + [ + "plot_results.py", + "--input-dir", + str(tmp_path / "in"), + "--output-dir", + str(output), + "--datasets", + "gist1m", + "--methods", + *methods, + ], + ) + if cpus[0] != cpus[1]: + with pytest.raises(ValueError, match="selected records span CPUs"): + plot_results.main() + assert not (output / "ivf-gist1m-qps-recall.svg").exists() + return + plot_results.main() + assert "CPU: CPU A" in (output / "ivf-gist1m-qps-recall.svg").read_text()