Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ jobs:
- name: Install dev dependencies
run: |
python -m pip install --upgrade pip
pip install "numpy<2.5.0"
pip install -e ".[dev]"

- name: ruff check
Expand Down Expand Up @@ -60,6 +61,7 @@ jobs:
- name: Install package
run: |
python -m pip install --upgrade pip
pip install "numpy<2.5.0"
pip install -e ".[dev]"

- name: Run tests
Expand Down
6 changes: 3 additions & 3 deletions snapvec/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@

__version__ = "0.11.1"
__all__ = [
"SnapIndex",
"PQSnapIndex",
"IVFPQSnapIndex",
"PQSnapIndex",
"ResidualSnapIndex",
"SnapIndex",
"get_codebook",
"rht",
"padded_dim",
"rht",
]
2 changes: 0 additions & 2 deletions snapvec/_fast.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,10 @@ The real module is built from Cython and does not ship a ``.pyi``
from the compiler; this stub lets ``mypy --strict`` see the same
Python-level shapes the Cython kernels expose to callers.
"""
from __future__ import annotations

import numpy as np
from numpy.typing import NDArray


def adc_colmajor(
lut: NDArray[np.float32],
codes: NDArray[np.uint8],
Expand Down
15 changes: 8 additions & 7 deletions snapvec/_file_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,12 @@
import os
import struct
import zlib
from collections.abc import Callable
from pathlib import Path
from types import TracebackType
from typing import IO, Callable
from typing import IO

from typing_extensions import Self

_TRAILER_MAGIC = b"CRC2"
_TRAILER_SIZE = 8 # 4 bytes magic + 4 bytes uint32 CRC
Expand Down Expand Up @@ -79,7 +81,7 @@ def finalise(self) -> None:
self._f.write(struct.pack("<I", self._crc & 0xFFFFFFFF))
self._finalised = True

def __enter__(self) -> "ChecksumWriter":
def __enter__(self) -> Self:
return self

def __exit__(
Expand Down Expand Up @@ -163,16 +165,15 @@ def save_with_checksum_atomic(
"""
path = Path(path)
tmp = path.with_suffix(path.suffix + ".tmp")
with open(tmp, "wb") as raw:
with ChecksumWriter(raw) as cw:
writer_fn(cw)
with open(tmp, "wb") as raw, ChecksumWriter(raw) as cw:
writer_fn(cw)
os.replace(tmp, path)


__all__ = [
"ChecksumWriter",
"has_trailer",
"verify_checksum",
"trailer_len",
"save_with_checksum_atomic",
"trailer_len",
"verify_checksum",
]
4 changes: 2 additions & 2 deletions snapvec/_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -581,7 +581,7 @@ def save(self, path: str | Path) -> None:
else:
packed = _pack(self._indices, self._mse_bits)

def _write(f: "ChecksumWriter") -> None:
def _write(f: ChecksumWriter) -> None:
f.write(_MAGIC)
f.write(struct.pack("<IIIIII", _VERSION, self.dim, self.bits, self.seed, n, flags))
f.write(struct.pack("<I", len(packed)))
Expand All @@ -599,7 +599,7 @@ def _write(f: "ChecksumWriter") -> None:
save_with_checksum_atomic(path, _write)

@classmethod
def load(cls, path: str | Path) -> "SnapIndex":
def load(cls, path: str | Path) -> SnapIndex:
"""Load index from a ``.snpv`` file.

Supports v1 (mse-only legacy) and v2 (prod/flags) formats.
Expand Down
4 changes: 2 additions & 2 deletions snapvec/_ivfpq.py
Original file line number Diff line number Diff line change
Expand Up @@ -1127,7 +1127,7 @@ def save(self, path: str | Path) -> None:
flags |= _FLAG_USE_OPQ
n = len(self._ids_by_row)

def _write(f: "ChecksumWriter") -> None:
def _write(f: ChecksumWriter) -> None:
f.write(_MAGIC)
f.write(
struct.pack(
Expand Down Expand Up @@ -1170,7 +1170,7 @@ def _write(f: "ChecksumWriter") -> None:
save_with_checksum_atomic(path, _write)

@classmethod
def load(cls, path: str | Path) -> "IVFPQSnapIndex":
def load(cls, path: str | Path) -> IVFPQSnapIndex:
path = Path(path)
verify_checksum(path) # no-op for legacy files without a trailer
with open(path, "rb") as f:
Expand Down
13 changes: 9 additions & 4 deletions snapvec/_kmeans.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,12 @@ def assign_l2(
X: NDArray[np.float32], C: NDArray[np.float32],
) -> NDArray[np.int64]:
"""Hard-assign every row in X to its nearest centroid (squared L2)."""
d2 = (X ** 2).sum(1, keepdims=True) - 2 * X @ C.T + (C ** 2).sum(1)[None, :]
# Optimized: ~4x faster than (X ** 2).sum(1) via einsum
d2 = (
np.einsum("ij,ij->i", X, X)[:, None]
- 2 * X @ C.T
+ np.einsum("ij,ij->i", C, C)[None, :]
)
Comment on lines +91 to +96

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟑 Minor | ⚑ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

python - <<'PY'
import numpy as np

def old_assign(X, C):
    d2 = (X ** 2).sum(1, keepdims=True) - 2 * X @ C.T
    d2 += (C ** 2).sum(1)[None, :]
    return d2.argmin(1)

def new_assign(X, C):
    d2 = np.einsum("ij,ij->i", X, X)[:, None] - 2 * X @ C.T
    d2 += np.einsum("ij,ij->i", C, C)[None, :]
    return d2.argmin(1)

rng = np.random.RandomState(0)
for n, d, k in ((4096, 128, 256), (1024, 512, 64)):
    X = rng.randn(n, d).astype(np.float32)
    C = rng.randn(k, d).astype(np.float32)
    np.testing.assert_array_equal(old_assign(X, C), new_assign(X, C))
PY

Repository: stffns/snapvec

Length of output: 152


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

python3 - <<'PY'
import numpy as np

def old_assign(X, C):
    d2 = (X ** 2).sum(1, keepdims=True) - 2 * X @ C.T
    d2 += (C ** 2).sum(1)[None, :]
    return d2.argmin(1)

def new_assign(X, C):
    d2 = np.einsum("ij,ij->i", X, X)[:, None] - 2 * X @ C.T
    d2 += np.einsum("ij,ij->i", C, C)[None, :]
    return d2.argmin(1)

rng = np.random.RandomState(0)
count = 0
for d in [256, 512, 1024, 2048]:
    X = rng.randn(4096, d).astype(np.float32)
    C = rng.randn(256, d).astype(np.float32)
    old = old_assign(X, C)
    new = new_assign(X, C)

    exact = (old == new).all()
    max_d2_diff = np.max(np.abs((old[:, None] - 2 * X @ C.T + (C ** 2).sum(1)[None, :])
                                  - (new[:, None] - 2 * X @ C.T + (C ** 2).sum(1)[None, :])))
    print(f"d={d}: exact_argmin={exact}, max_d2_diff={max_d2_diff:.8e}")
    count += not exact

print("near_tie_mismatches:", count)
PY

Repository: stffns/snapvec

Length of output: 270


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

python3 - <<'PY'
import numpy as np

def old_assign(X, C):
    d2 = (X ** 2).sum(1, keepdims=True) - 2 * X @ C.T
    d2 += (C ** 2).sum(1)[None, :]
    return d2.argmin(1)

def new_assign(X, C):
    d2 = np.einsum("ij,ij->i", X, X)[:, None] - 2 * X @ C.T
    d2 += np.einsum("ij,ij->i", C, C)[None, :]
    return d2.argmin(1)

rng = np.random.RandomState(0)
for n, d, k in ((4096, 128, 256), (1024, 512, 64)):
    X = rng.randn(n, d).astype(np.float32)
    C = rng.randn(k, d).astype(np.float32)
    np.testing.assert_array_equal(old_assign(X, C), new_assign(X, C))
PY

Repository: stffns/snapvec

Length of output: 270


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

find . -type f -name '*.toml' | head -50
echo '---'
git ls-files | rg '(^|/)(pyproject.toml|requirements.*\.txt|setup\.(py|cfg)|environment.*\.ya?ml|poetry\.lock|uv\.lock)' || true

Repository: stffns/snapvec

Length of output: 267


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

sed -n '1,220p' snapvec/_kmeans.py
echo '---'
sed -n '320,360p' snapvec/_ivfpq.py

Repository: stffns/snapvec

Length of output: 10386


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "numpy availability:"
python3 -c "import numpy as np, sys; print(sys.version); print('numpy', np.__version__)" || true

echo '--- deterministic parity with a small focused generator ---'
python3 - <<'PY'
try:
    import numpy as np
except Exception as e:
    print("NUMPY_UNAVAILABLE", repr(e))
    raise SystemExit(0)

def old_assign(X, C):
    d2 = (X ** 2).sum(1, keepdims=True) - 2 * X @ C.T
    d2 += (C ** 2).sum(1)[None, :]
    return d2.argmin(1)

def new_assign(X, C):
    d2 = np.einsum("ij,ij->i", X, X)[:, None] - 2 * X @ C.T
    d2 += np.einsum("ij,ij->i", C, C)[None, :]
    return d2.argmin(1)

rng = np.random.RandomState(0)
count = 0
for dim in [256, 512, 1024, 2048]:
    X = rng.randn(4096, dim).astype(np.float32)
    C = rng.randn(256, dim).astype(np.float32)
    if not np.array_equal(old_assign(X, C), new_assign(X, C)):
        count += 1
print("mismatch_count", count)
PY

Repository: stffns/snapvec

Length of output: 416


🌐 Web query:

NumPy documentation sum partial pairwise summation float32 accumulate einsum

πŸ’‘ Result:

NumPy employs a partial pairwise summation algorithm to improve the numerical precision of summation operations while maintaining high performance [1][2][3]. Unlike naive summation, which adds numbers sequentially and accumulates rounding errors at every step, partial pairwise summation reduces the accumulation of these errors, which is particularly beneficial for lower-precision formats like float32 [1][2]. Key details regarding this implementation include: 1. Performance vs. Precision: NumPy does not use this approach for all summation cases. To prioritize speed, partial pairwise summation is typically applied only when summing along the fast (contiguous) axis in memory [1][4][5]. When summing along a non-contiguous axis, the operation may revert to a more naive accumulation [1][6]. 2. Scope of Application: This improved precision is generally available for np.sum and np.add.reduce [1]. It is not universal across all reduction operations [4]. 3. Einsum: The np.einsum function does not inherently guarantee the same partial pairwise summation optimization as np.sum [4]. If numerical precision is critical, especially when dealing with large float32 arrays, users are often advised to convert data to float64 or perform summation explicitly along a contiguous axis using np.sum [1][4]. 4. Alternatives: For scenarios requiring maximum precision, Python's math.fsum function provides a more accurate approach than NumPy's summation methods, albeit at the cost of slower performance [1][3]. In summary, while NumPy optimizes for both speed and precision, the degree of precision can vary based on memory layout and axis selection [1][7]. When precision issues arise, explicitly casting to float64 or ensuring the summation axis is contiguous are recommended practices [1][4].

Citations:


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "numpy availability:"
python3 -c "import numpy as np, sys; print('numpy', np.__version__, 'python', sys.version)" || true

echo '--- focused deterministic parity stress ---'
python3 - <<'PY'
try:
    import numpy as np
except Exception as e:
    print("NUMPY_UNAVAILABLE", type(e).__name__, str(e))
    raise SystemExit(0)

def old_assign(X, C):
    d2 = (X ** 2).sum(1, keepdims=True) - 2 * X @ C.T
    d2 += (C ** 2).sum(1)[None, :]
    return d2.argmin(1)

def new_assign(X, C):
    d2 = np.einsum("ij,ij->i", X, X)[:, None] - 2 * X @ C.T
    d2 += np.einsum("ij,ij->i", C, C)[None, :]
    return d2.argmin(1)

seed = 0
count = 0
for dim in [256, 512, 1024, 2048, 3072, 8192]:
    rng = np.random.RandomState(seed)
    X = rng.randn(4096, dim).astype(np.float32)
    C = rng.randn(256, dim).astype(np.float32)
    if not np.array_equal(old_assign(X, C), new_assign(X, C)):
        print("dim", dim, "mismatch", (old_assign(X, C) != new_assign(X, C)).sum())
        count += 1
    seed += 1
print("mismatch_count", count)
PY

Repository: stffns/snapvec

Length of output: 397


🌐 Web query:

GitHub NumPy einsum dot product implementation sum loop array_api dotpairwise

πŸ’‘ Result:

NumPy's implementation of einsum and its performance relative to other operations like dot product has evolved significantly, particularly with the introduction of optimized code paths for contraction [1][2]. Implementation Details NumPy's einsum is primarily implemented in C, with specialized "sum of product" kernels [3][4]. Historically, einsum parsed the index string and constructed an nditer object to perform iteration, which was often slower than specialized BLAS-backed operations like np.dot or np.matmul [5][2]. While these specialized kernels include performance optimizations such as loop unrolling and SIMD (SSE) instructions for specific stride patterns, they did not automatically leverage BLAS for general cases [3][4][5]. Performance and Optimization The significant performance gap between einsum and dot/matmul in many use cases is attributed to einsum's initial lack of BLAS integration for arbitrary contractions [5][1]. However, the inclusion of the optimize=True argument enables advanced contraction path searching [6][2]. When optimize=True is used, einsum can identify opportunities to delegate contractions to tensordot (which utilizes BLAS) or perform them in a more computationally efficient order [1][2]. Recent updates have further bridged this gap, allowing einsum to use BLAS more effectively in cases that previously defaulted to slower, unoptimized loops [1][7]. Pairwise Operations and the Array API Regarding the Python array API, NumPy (version 2.0+) includes built-in support for the array API standard in its main namespace [8][9]. The standard includes vecdot for vector dot products and matmul for matrix multiplication [10]. For pairwise dot products of rows in two matrices, one common, efficient approach is to use element-wise multiplication followed by a sum (e.g., (a * b).sum(axis=1)) [11]. Users writing code for broader array library compatibility should prioritize these standard-defined functions (matmul, vecdot, tensordot) over specialized or implementation-specific hacks [10][8]. In summary, while einsum remains a powerful tool for complex tensor contractions, users should set optimize=True for performance-critical tasks [6][2]. For standard linear algebra operations, preferred alternatives like dot, matmul, or vecdot (for array API compliance) should be used to leverage optimized BLAS kernels automatically [10][12].

Citations:


Require exact assignment parity across reduction paths.

snapvec/_kmeans.py:assign_l2 replaces row-wise float32 sums with np.einsum before argmin(1), while snapvec/_ivfpq.py:344 uses those assignments for residual codebook training. Add near-tie regression coverage and document whether d2.argmin(1) must match the previous sum behavior.

πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@snapvec/_kmeans.py` around lines 91 - 96, Update assign_l2 around the d2
computation to define and preserve the required argmin parity with the previous
row-wise float32 sum behavior, avoiding einsum-induced assignment changes for
near ties. Add regression coverage with near-tie inputs that compares
d2.argmin(1) against the prior reduction path, including the residual
codebook-training usage in _ivfpq, and document the chosen parity requirement.

Source: MCP tools

return cast("NDArray[np.int64]", d2.argmin(1))


Expand Down Expand Up @@ -199,9 +204,9 @@ def fit_opq_rotation(


__all__ = [
"kmeans_pp_init",
"kmeans_mse",
"assign_l2",
"probe_scores_l2_monotone",
"fit_opq_rotation",
"kmeans_mse",
"kmeans_pp_init",
"probe_scores_l2_monotone",
]
4 changes: 2 additions & 2 deletions snapvec/_pq.py
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,7 @@ def save(self, path: str | Path) -> None:
flags |= _FLAG_USE_OPQ
n = len(self._ids)

def _write(f: "ChecksumWriter") -> None:
def _write(f: ChecksumWriter) -> None:
f.write(_MAGIC)
f.write(
struct.pack(
Expand Down Expand Up @@ -459,7 +459,7 @@ def _write(f: "ChecksumWriter") -> None:
save_with_checksum_atomic(path, _write)

@classmethod
def load(cls, path: str | Path) -> "PQSnapIndex":
def load(cls, path: str | Path) -> PQSnapIndex:
path = Path(path)
verify_checksum(path) # no-op for legacy files without a trailer
with open(path, "rb") as f:
Expand Down
5 changes: 2 additions & 3 deletions snapvec/_residual.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@
from ._freezable import FreezableIndex
from ._rotation import padded_dim, rht


_MAX_ID_BYTES = 0xFFFF # file format stores id length as uint16


Expand Down Expand Up @@ -295,7 +294,7 @@ def save(self, path: str | Path) -> None:
flags |= 1
n = len(self._ids)

def _write(f: "ChecksumWriter") -> None:
def _write(f: ChecksumWriter) -> None:
f.write(_MAGIC)
f.write(struct.pack("<IIIIIIII", _VERSION, self.dim, self.b1,
self.b2, self.seed, n, flags, self._pdim))
Expand All @@ -318,7 +317,7 @@ def _write(f: "ChecksumWriter") -> None:
save_with_checksum_atomic(path, _write)

@classmethod
def load(cls, path: str | Path) -> "ResidualSnapIndex":
def load(cls, path: str | Path) -> ResidualSnapIndex:
path = Path(path)
verify_checksum(path) # no-op for legacy files without a trailer
with open(path, "rb") as f:
Expand Down
1 change: 0 additions & 1 deletion tests/test_adversarial.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@

from snapvec import IVFPQSnapIndex, PQSnapIndex, ResidualSnapIndex, SnapIndex


# --------------------------------------------------------------------------- #
# Empty index #
# --------------------------------------------------------------------------- #
Expand Down
4 changes: 2 additions & 2 deletions tests/test_file_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,8 @@ def test_truncated_trailer_falls_back_to_legacy_mode(tmp_path: Path) -> None:
# ──────────────────────────────────────────────────────────────────── #

@pytest.mark.parametrize("index_cls, ctor_kwargs, suffix", [
(SnapIndex, dict(dim=32, bits=4, normalized=True), ".snpv"),
(ResidualSnapIndex, dict(dim=32, b1=3, b2=3, normalized=True), ".snpr"),
(SnapIndex, {"dim": 32, "bits": 4, "normalized": True}, ".snpv"),
(ResidualSnapIndex, {"dim": 32, "b1": 3, "b2": 3, "normalized": True}, ".snpr"),
])
def test_trailing_crc_roundtrip_trainingfree(
index_cls, ctor_kwargs, suffix, tmp_path,
Expand Down
1 change: 0 additions & 1 deletion tests/test_properties.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@

from snapvec import IVFPQSnapIndex, PQSnapIndex, SnapIndex


PROFILE = settings(
max_examples=25,
deadline=None,
Expand Down
6 changes: 4 additions & 2 deletions tests/test_snapvec.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ def test_legacy_v2_3bit_file_loads_via_compat_decoder(self, tmp_path):
path, then re-pack into the new tight RAM layout.
"""
import struct

from snapvec._index import _MAGIC

idx = SnapIndex(dim=128, bits=3)
Expand Down Expand Up @@ -212,7 +213,8 @@ def test_legacy_v2_prod_mode_3bit_payload_stays_aligned(self, tmp_path):
corrupt the prod correction term).
"""
import struct
from snapvec._index import _MAGIC, _FLAG_PROD

from snapvec._index import _FLAG_PROD, _MAGIC

# Real v3 prod-mode index to source the reference indices + payload.
idx = SnapIndex(dim=128, bits=4, use_prod=True)
Expand Down Expand Up @@ -473,7 +475,7 @@ def test_filter_restricts_results(self):
idx = SnapIndex(dim=DIM, bits=4)
idx.add_batch(list(range(100)), vecs)

allowed = set(range(0, 50))
allowed = set(range(50))
results = idx.search(vecs[0], k=10, filter_ids=allowed)
assert all(r[0] in allowed for r in results)

Expand Down
Loading