Skip to content

python-math #48: feat(math): vectorize legacy kmeans hot paths (item 9a) - #2679

Draft
jucor wants to merge 1 commit into
spr/edge/c5632acefrom
spr/edge/52a753ee
Draft

python-math #48: feat(math): vectorize legacy kmeans hot paths (item 9a)#2679
jucor wants to merge 1 commit into
spr/edge/c5632acefrom
spr/edge/52a753ee

Conversation

@jucor

@jucor jucor commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

What

Replace the per-pair Python _euclidean scans in legacy_kmeans.py (the quirk-for-quirk port of the Clojure engine's k-means) with per-center BLAS distance columns — BIT-IDENTICAL outputs, certified twice by the full certification battery (which replays 20 recorded dataset entries through the engine and compares against Clojure reference recordings). The bit-identity plan ("Plan A") held: no tie-break divergence appeared, so no fallback was needed.

Why

Hot paths (cProfile at 4000 participants x 300 comments x 240k votes, 112.5s total): _euclidean 26.4M calls / 61.8s cumulative; np.array_equal 8.0M calls / 38.9s cumulative via the O(n^2) n_distinct_rows scan; cluster_step 65.3s cumulative.

Bit-identity engineering

Empirically probed on numpy 1.26.4 / OpenBLAS 0.3.23 arm64:

  • REJECTED: X @ c (dgemv) — it reassociates relative to float(np.dot(row, c)) for d>=4; einsum and (m*m).sum(1) differ even at d=2. Last-ulp differences are amplified by quirk Q11 — the legacy distance formula d^2 = |a|^2+|b|^2-2ab cancels catastrophically, so near-coincident points tie at EXACTLY 0.0 — into changed 0.0-ties, i.e. changed cluster lineage.
  • SHIPPED: batched matmul — (n,1,d)@(n,d,1) for row norms and (n,1,d)@(d,1) for cross products — bit-equal to float(np.dot(...)) on all 85 probed shape/scale combos (n=1..33422, d=1..783, scales 1e-8/1/1e8, C and F memory order), including the vw dataset's knife-edge pair (both distances EXACTLY 0.0) and NaN propagation.
  • The column combine keeps the scalar code's exact order and associativity: (row_norms + |c|^2) - 2.0*cross; the floor is np.where(d2 < 0.0, 0.0, d2) so NaN propagates (never a maximum-clamp); same IEEE sqrt.

Changes (legacy_kmeans.py only)

  • New _row_norms + _euclidean_col (bit-identical distance columns).
  • cluster_step: the columns are folded in the existing Clojure scan order (hash order for >8 clusters, input order for <=8) with the scalar rule d <= best (ties go to the LATER cluster; NaN never wins); members are regrouped by ascending row index, which equals the scalar append order.
  • most_distal: same inner fold; exact outer last-wins reduction (a NaN first row sticks; later NaN rows are skipped; otherwise last argmax).
  • n_distinct_rows(bound=): vectorized elimination passes with array_equal(..., equal_nan=True) semantics; clean_start_clusters passes bound=k so min(k, .) stays exact without the O(n^2) count.
  • _recenter_center: index-gather (same values, same mean).
  • _euclidean / same_clustering / weighted_mean semantics unchanged.

Testing (TDD)

RED = 4 ImportError pins (_euclidean_col / _row_norms) plus a TypeError pin for the bound kwarg. 11 new tests: bit-equality against a VERBATIM _scalar_d2_reference copy of the pre-vectorization formula (exact ==), the knife-edge 0.0 case, NaN propagation, cluster_step / most_distal equivalence against verbatim scalar reference loops (grid-tie fixtures, the >8 and <=8 scan orders, weights, k>n, NaN), and bounded-distinct semantics (NaN, -0.0 == 0.0).

Results

  • Full suite: 1171 passed / 22 skipped / 44 xfailed / 2 xpassed (baseline 1160 + 11 new; zero new failures). Pyright clean.
  • Battery: certify 20 entries — MATCH=20, DIVERGENCE=0, SKIPPED=0, ERROR=0 — TWICE (on the vectorized tree; final bytes after an annotation-only cleanup).
  • Bench at 8000 x 400 x 480k votes: cold 68.53s → 3.58s (19x), warm 159.53s → 3.77s (42x).
  • Bench at the FULL 33,422 x 783 / 2.0M-vote shape: cold 28.15s, warm 26.66s — versus ~31 min warm before (~70x); the largest production conversation now ticks in under 30 seconds.

Co-Authored-By: Claude Fable 5 noreply@anthropic.com

commit-id:52a753ee


Stack:


⚠️ Part of a stack created by spr. Do not merge manually using the UI - doing so may have unexpected results.

This was referenced Jul 27, 2026
This was referenced Jul 27, 2026
@jucor
jucor force-pushed the spr/edge/52a753ee branch from 1afefae to 9841cab Compare July 28, 2026 00:11
@jucor jucor changed the title feat(math): vectorize legacy kmeans hot paths (item 9a) python-math #48: feat(math): vectorize legacy kmeans hot paths (item 9a) Jul 28, 2026
## What

Replace the per-pair Python `_euclidean` scans in `legacy_kmeans.py` (the quirk-for-quirk port of the Clojure engine's k-means) with per-center BLAS distance columns — BIT-IDENTICAL outputs, certified twice by the full certification battery (which replays 20 recorded dataset entries through the engine and compares against Clojure reference recordings). The bit-identity plan ("Plan A") held: no tie-break divergence appeared, so no fallback was needed.

## Why

Hot paths (cProfile at 4000 participants x 300 comments x 240k votes, 112.5s total): `_euclidean` 26.4M calls / 61.8s cumulative; `np.array_equal` 8.0M calls / 38.9s cumulative via the O(n^2) `n_distinct_rows` scan; `cluster_step` 65.3s cumulative.

## Bit-identity engineering

Empirically probed on numpy 1.26.4 / OpenBLAS 0.3.23 arm64:

- REJECTED: `X @ c` (dgemv) — it reassociates relative to `float(np.dot(row, c))` for d>=4; `einsum` and `(m*m).sum(1)` differ even at d=2. Last-ulp differences are amplified by quirk Q11 — the legacy distance formula d^2 = |a|^2+|b|^2-2ab cancels catastrophically, so near-coincident points tie at EXACTLY 0.0 — into changed 0.0-ties, i.e. changed cluster lineage.
- SHIPPED: batched matmul — `(n,1,d)@(n,d,1)` for row norms and `(n,1,d)@(d,1)` for cross products — bit-equal to `float(np.dot(...))` on all 85 probed shape/scale combos (n=1..33422, d=1..783, scales 1e-8/1/1e8, C and F memory order), including the vw dataset's knife-edge pair (both distances EXACTLY 0.0) and NaN propagation.
- The column combine keeps the scalar code's exact order and associativity: `(row_norms + |c|^2) - 2.0*cross`; the floor is `np.where(d2 < 0.0, 0.0, d2)` so NaN propagates (never a maximum-clamp); same IEEE sqrt.

## Changes (`legacy_kmeans.py` only)

- New `_row_norms` + `_euclidean_col` (bit-identical distance columns).
- `cluster_step`: the columns are folded in the existing Clojure scan order (hash order for >8 clusters, input order for <=8) with the scalar rule `d <= best` (ties go to the LATER cluster; NaN never wins); members are regrouped by ascending row index, which equals the scalar append order.
- `most_distal`: same inner fold; exact outer last-wins reduction (a NaN first row sticks; later NaN rows are skipped; otherwise last argmax).
- `n_distinct_rows(bound=)`: vectorized elimination passes with `array_equal(..., equal_nan=True)` semantics; `clean_start_clusters` passes `bound=k` so `min(k, .)` stays exact without the O(n^2) count.
- `_recenter_center`: index-gather (same values, same mean).
- `_euclidean` / `same_clustering` / `weighted_mean` semantics unchanged.

## Testing (TDD)

RED = 4 ImportError pins (`_euclidean_col` / `_row_norms`) plus a TypeError pin for the `bound` kwarg. 11 new tests: bit-equality against a VERBATIM `_scalar_d2_reference` copy of the pre-vectorization formula (exact ==), the knife-edge 0.0 case, NaN propagation, `cluster_step` / `most_distal` equivalence against verbatim scalar reference loops (grid-tie fixtures, the >8 and <=8 scan orders, weights, k>n, NaN), and bounded-distinct semantics (NaN, -0.0 == 0.0).

## Results

- Full suite: 1171 passed / 22 skipped / 44 xfailed / 2 xpassed (baseline 1160 + 11 new; zero new failures). Pyright clean.
- Battery: certify 20 entries — MATCH=20, DIVERGENCE=0, SKIPPED=0, ERROR=0 — TWICE (on the vectorized tree; final bytes after an annotation-only cleanup).
- Bench at 8000 x 400 x 480k votes: cold 68.53s → 3.58s (19x), warm 159.53s → 3.77s (42x).
- Bench at the FULL 33,422 x 783 / 2.0M-vote shape: cold 28.15s, warm 26.66s — versus ~31 min warm before (~70x); the largest production conversation now ticks in under 30 seconds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

commit-id:52a753ee
@jucor
jucor force-pushed the spr/edge/52a753ee branch from 9841cab to 2a9cf4e Compare July 28, 2026 01:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant