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
3 changes: 3 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
## 2024-05-18 - Fast row-wise Euclidean norm in pure NumPy
**Learning:** In performance-critical paths, computing the batch norm of a 2D array via `np.linalg.norm(arr, axis=1)` is relatively slow. Using `np.sqrt(np.einsum('ij,ij->i', arr, arr))` is significantly faster (~4x speedup on a laptop CPU for typical batch sizes). If `keepdims=True` behavior is needed, appending `[:, np.newaxis]` matches the original shape seamlessly.
**Action:** Always prefer `np.sqrt(np.einsum('ij,ij->i', arr, arr))` over `np.linalg.norm(arr, axis=1)` when computing row-wise vector norms in NumPy to eliminate dispatch overhead and improve execution speed.
## 2024-08-10 - Optimizing loop-level norm calculations in PQ encoding
**Learning:** While replacing `(X ** 2).sum(1)` with `np.einsum` gives massive ~4x speedups for large contiguous arrays, applying it inside the `add_batch` inner loop over `M` sub-quantizers (where arrays are smaller, e.g., `(N, d_sub)`) yields a smaller but still measurable ~1.15x speedup. The true vectorization fix (batching across `M`) requires large memory allocations `(N, M, K)` which actually decreases performance and causes OOMs, so the `einsum` micro-optimization inside the loop is the safest win.
**Action:** When optimizing tight loops with small array slices, prioritize `einsum` to eliminate intermediate allocations without increasing overall memory footprint.
Comment on lines +4 to +6

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

πŸ“ Maintainability & Code Quality | 🟑 Minor | ⚑ Quick win

Fix the Markdown spacing around the new heading.

markdownlint-cli2 reports MD022 violations because the heading has no blank line before or after it. Add both blank lines.

Proposed fix
+
 ## 2024-08-10 - Optimizing loop-level norm calculations in PQ encoding
+
 **Learning:** While replacing `(X ** 2).sum(1)` with `np.einsum` gives massive ~4x speedups for large contiguous arrays, applying it inside the `add_batch` inner loop over `M` sub-quantizers (where arrays are smaller, e.g., `(N, d_sub)`) yields a smaller but still measurable ~1.15x speedup. The true vectorization fix (batching across `M`) requires large memory allocations `(N, M, K)` which actually decreases performance and causes OOMs, so the `einsum` micro-optimization inside the loop is the safest win.
 **Action:** When optimizing tight loops with small array slices, prioritize `einsum` to eliminate intermediate allocations without increasing overall memory footprint.
+

This finding is based on the supplied static analysis warning.

πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
## 2024-08-10 - Optimizing loop-level norm calculations in PQ encoding
**Learning:** While replacing `(X ** 2).sum(1)` with `np.einsum` gives massive ~4x speedups for large contiguous arrays, applying it inside the `add_batch` inner loop over `M` sub-quantizers (where arrays are smaller, e.g., `(N, d_sub)`) yields a smaller but still measurable ~1.15x speedup. The true vectorization fix (batching across `M`) requires large memory allocations `(N, M, K)` which actually decreases performance and causes OOMs, so the `einsum` micro-optimization inside the loop is the safest win.
**Action:** When optimizing tight loops with small array slices, prioritize `einsum` to eliminate intermediate allocations without increasing overall memory footprint.
## 2024-08-10 - Optimizing loop-level norm calculations in PQ encoding
**Learning:** While replacing `(X ** 2).sum(1)` with `np.einsum` gives massive ~4x speedups for large contiguous arrays, applying it inside the `add_batch` inner loop over `M` sub-quantizers (where arrays are smaller, e.g., `(N, d_sub)`) yields a smaller but still measurable ~1.15x speedup. The true vectorization fix (batching across `M`) requires large memory allocations `(N, M, K)` which actually decreases performance and causes OOMs, so the `einsum` micro-optimization inside the loop is the safest win.
**Action:** When optimizing tight loops with small array slices, prioritize `einsum` to eliminate intermediate allocations without increasing overall memory footprint.
🧰 Tools
πŸͺ› markdownlint-cli2 (0.23.2)

[warning] 4-4: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Above

(MD022, blanks-around-headings)


[warning] 4-4: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)

πŸ€– 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 @.jules/bolt.md around lines 4 - 6, Add a blank line immediately before and
after the new β€œ2024-08-10 - Optimizing loop-level norm calculations in PQ
encoding” heading in the Markdown document, preserving the existing content and
formatting otherwise.

Source: Linters/SAST tools

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ line-length = 100
target-version = "py310"

[tool.mypy]
python_version = "3.10"
python_version = "3.12"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

πŸ“ Maintainability & Code Quality | 🟑 Minor | ⚑ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n --hidden --glob '!.git/**' \
  'requires-python|python_version|python-version|mypy|3\.(10|11|12)' .

Repository: stffns/snapvec

Length of output: 3116


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== pyproject relevant sections =="
sed -n '1,90p' pyproject.toml

echo
echo "== lockfile dependency versions =="
fd -i '^(requirements.*\.txt|.*lock.*\.toml|Pipfile|poetry\.lock)$' . -x sh -c 'echo "--- $1"; sed -n "1,220p" "$1"' sh {}

echo
echo "== mypy usage / config references =="
rg -n --hidden --glob '!.git/**' 'tool\.mypy|MYPYPATH|plugins\s*=|python_version|mypy --strict' pyproject.toml .github snapvec tests docs examples papers CLAUDE.md CONTRIBUTING.md CHANGELOG.md

Repository: stffns/snapvec

Length of output: 3031


Align the mypy target with the package’s lowest supported runtime.

pyproject.toml declares requires-python = ">=3.10" and Python 3.10 classifiers, but tool.mypy.python_version is pinned to 3.12, so mypy can allow APIs missing from the supported runtime. Lower this to 3.10 or bump the runtime contract for Python 3.10/3.11.

πŸ€– 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 `@pyproject.toml` at line 70, Update the tool.mypy.python_version setting in
pyproject.toml from 3.12 to 3.10 so static type checking matches the package’s
lowest supported runtime declared by requires-python and the Python 3.10
classifiers.

Source: MCP tools

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:

#!/bin/bash
set -euo pipefail

files=$(
  fd -H -t f . |
    rg '(^|/)(pyproject\.toml|setup\.cfg|setup\.py|tox\.ini|noxfile\.py|\.python-version|\.tool-versions|Dockerfile.*|.*\.(yml|yaml))$'
)

while IFS= read -r file; do
  rg -n -C 2 \
    'requires-python|python_requires|python-version|python_version|target-version|FROM python|3\.(10|11|12)' \
    "$file" || true
done <<< "$files"

Repository: stffns/snapvec

Length of output: 2323


Set python_version to 3.10.

The package declares requires-python = ">=3.10", and CI tests Python 3.10. A Mypy target of 3.12 does not match the minimum supported version.

πŸ€– 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 `@pyproject.toml` at line 70, Update the python_version setting in the project
configuration from 3.12 to 3.10 so the Mypy target matches the package’s minimum
supported Python version and CI coverage.

strict = true
warn_return_any = true
warn_unused_ignores = true
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: 7 additions & 8 deletions snapvec/_file_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@
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

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

def __enter__(self) -> "ChecksumWriter":
def __enter__(self) -> ChecksumWriter: # noqa: PYI034
return self

def __exit__(
Expand Down Expand Up @@ -163,16 +163,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
8 changes: 4 additions & 4 deletions snapvec/_kmeans.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ def assign_l2(
) -> 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, :]
return cast("NDArray[np.int64]", d2.argmin(1))
return cast("NDArray[np.int64]", d2.argmin(1)) # type: ignore[redundant-cast]


def probe_scores_l2_monotone(
Expand Down Expand Up @@ -199,9 +199,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",
]
9 changes: 5 additions & 4 deletions snapvec/_pq.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,10 +307,11 @@ def add_batch(
codes = np.empty((self.M, len(arr)), dtype=np.uint8)
for j in range(self.M):
Xj = pre[:, j * self._d_sub : (j + 1) * self._d_sub]
# Optimized: ~1.15x faster than (arr ** 2).sum(1) by avoiding intermediate array allocations

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

πŸ“ Maintainability & Code Quality | 🟑 Minor | ⚑ Quick win

Correct the benchmark comment to name Xj.

The replaced expression is (Xj ** 2).sum(1), not (arr ** 2).sum(1). arr has the full embedding dimension and would compute a different norm.

This uses the supplied PR objective.

πŸ€– 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/_pq.py` at line 310, Correct the optimization comment near the
relevant norm computation to reference Xj instead of arr, accurately describing
the replaced expression as (Xj ** 2).sum(1).

d2 = (
(Xj ** 2).sum(1, keepdims=True)
np.einsum('ij,ij->i', Xj, Xj)[:, None]
- 2 * Xj @ self._codebooks[j].T
+ (self._codebooks[j] ** 2).sum(1)[None, :]
+ np.einsum('ij,ij->i', self._codebooks[j], self._codebooks[j])[None, :]
)
codes[j] = d2.argmin(1).astype(np.uint8)

Expand Down Expand Up @@ -426,7 +427,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 +460,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