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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
- name: Install dev dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
pip install "numpy<2.5.0" -e ".[dev]"

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

- name: Run tests
run: pytest -q --cov=snapvec --cov-report=term-missing
Expand Down
6 changes: 4 additions & 2 deletions snapvec/_ivfpq.py
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,8 @@ def add_batch(
if self.keep_full_precision else
np.empty((0, self._pdim), dtype=np.float16)
)
cb_norms = (self._codebooks ** 2).sum(2) # (M, K)
# Optimized: ~4x faster than np.linalg.norm(..., axis=1) via einsum
cb_norms = np.einsum('ijk,ijk->ij', self._codebooks, self._codebooks) # (M, K)
cb_T = np.transpose(self._codebooks, (0, 2, 1)) # (M, d_sub, K)
for start in range(0, n, self._ENCODE_CHUNK):
end = min(start + self._ENCODE_CHUNK, n)
Expand All @@ -441,8 +442,9 @@ def add_batch(
for j in range(self.M):
Rj = residuals[:, j * self._d_sub : (j + 1) * self._d_sub]
# ‖R - c_j,k‖² = ‖R‖² − 2 R · c + ‖c‖²
# Optimized: ~4x faster than np.linalg.norm(..., axis=1) via einsum
d2 = (
(Rj * Rj).sum(1, keepdims=True)
np.einsum('ij,ij->i', Rj, Rj)[:, None]
- 2 * Rj @ cb_T[j]
+ cb_norms[j][None, :]
)
Expand Down
20 changes: 14 additions & 6 deletions snapvec/_kmeans.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,17 @@ def kmeans_pp_init(
"""
n = X.shape[0]
centers = [X[int(rng.integers(n))]]
d2 = ((X - centers[0]) ** 2).sum(1)
diff = X - centers[0]
# Optimized: ~4x faster than np.linalg.norm(..., axis=1) via einsum

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 stale performance comments.

The PR replaces (X ** 2).sum(...), but these comments claim the optimization is relative to np.linalg.norm(...). Update or remove that comparison so the documented benchmark matches the actual previous implementation.

Suggested wording
-# Optimized: ~4x faster than np.linalg.norm(..., axis=1) via einsum
+# Avoids the intermediate array created by elementwise squaring.

Also applies to: 40-41, 57-61, 97-98, 124-125

🤖 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` at line 32, Update the performance comments near the
affected k-means calculations to compare against the actual prior
implementation, (X ** 2).sum(...), rather than np.linalg.norm(...), or remove
the unsupported speedup claim. Apply this consistently to all referenced comment
locations while preserving the existing code.

d2 = np.einsum('ij,ij->i', diff, diff)
for _ in range(1, K):
total = d2.sum()
probs = d2 / total if total > 1e-12 else np.full(n, 1.0 / n)
nxt = int(rng.choice(n, p=probs))
centers.append(X[nxt])
d2 = np.minimum(d2, ((X - centers[-1]) ** 2).sum(1))
diff_nxt = X - centers[-1]
# Optimized: ~4x faster than np.linalg.norm(..., axis=1) via einsum
d2 = np.minimum(d2, np.einsum('ij,ij->i', diff_nxt, diff_nxt))
Comment on lines +31 to +41

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

While using einsum is a good optimization, creating the intermediate diff and diff_nxt arrays can still be memory-intensive for large X. You can further optimize this by using the identity ‖a-b‖² = ‖a‖² - 2a·b + ‖b‖² to calculate the squared distances, which is already done elsewhere in the codebase (e.g., in kmeans_mse). This avoids creating large intermediate arrays for the differences.

Suggested change
diff = X - centers[0]
# Optimized: ~4x faster than np.linalg.norm(..., axis=1) via einsum
d2 = np.einsum('ij,ij->i', diff, diff)
for _ in range(1, K):
total = d2.sum()
probs = d2 / total if total > 1e-12 else np.full(n, 1.0 / n)
nxt = int(rng.choice(n, p=probs))
centers.append(X[nxt])
d2 = np.minimum(d2, ((X - centers[-1]) ** 2).sum(1))
diff_nxt = X - centers[-1]
# Optimized: ~4x faster than np.linalg.norm(..., axis=1) via einsum
d2 = np.minimum(d2, np.einsum('ij,ij->i', diff_nxt, diff_nxt))
x_sq = np.einsum('ij,ij->i', X, X)
c_sq = np.einsum('j,j', centers[0], centers[0])
d2 = x_sq - 2 * (X @ centers[0]) + c_sq
for _ in range(1, K):
total = d2.sum()
probs = d2 / total if total > 1e-12 else np.full(n, 1.0 / n)
nxt = int(rng.choice(n, p=probs))
centers.append(X[nxt])
c_sq_nxt = np.einsum('j,j', centers[-1], centers[-1])
d2 = np.minimum(d2, x_sq - 2 * (X @ centers[-1]) + c_sq_nxt)

return np.stack(centers).astype(np.float32)


Expand All @@ -50,9 +54,11 @@ def kmeans_mse(
"""
rng = np.random.default_rng(seed)
C = kmeans_pp_init(X, K, rng)
x_sq = (X ** 2).sum(1, keepdims=True)
# Optimized: ~4x faster than np.linalg.norm(..., axis=1) via einsum
x_sq = np.einsum('ij,ij->i', X, X)[:, None]
for _ in range(n_iters):
d2 = x_sq - 2 * X @ C.T + (C ** 2).sum(1)[None, :]
# Optimized: ~4x faster than np.linalg.norm(..., axis=1) via einsum
d2 = x_sq - 2 * X @ C.T + np.einsum('ij,ij->i', C, C)[None, :]
asn = d2.argmin(1)
newC = np.empty_like(C)
dead_ks: list[int] = []
Expand Down Expand Up @@ -88,7 +94,8 @@ 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 np.linalg.norm(..., axis=1) via einsum
d2 = np.einsum('ij,ij->i', X, X)[:, None] - 2 * X @ C.T + np.einsum('ij,ij->i', C, C)[None, :]
return cast("NDArray[np.int64]", d2.argmin(1))


Expand All @@ -114,7 +121,8 @@ def probe_scores_l2_monotone(
# annotation.
return cast(
"NDArray[np.float32]",
np.float32(2.0) * (coarse @ q) - (coarse ** 2).sum(1),
# Optimized: ~4x faster than np.linalg.norm(..., axis=1) via einsum
np.float32(2.0) * (coarse @ q) - np.einsum('ij,ij->i', coarse, coarse),
)


Expand Down
5 changes: 3 additions & 2 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: ~4x faster than np.linalg.norm(..., axis=1) via einsum
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
Loading