Skip to content

kmeans: cluster-parallel update via counting sort (O(n·d), thread-count-invariant) - #1

Merged
SilvioM97 merged 2 commits into
TusKANNy:mainfrom
robro612:pr1-scatter-add
Aug 12, 2026
Merged

kmeans: cluster-parallel update via counting sort (O(n·d), thread-count-invariant)#1
SilvioM97 merged 2 commits into
TusKANNy:mainfrom
robro612:pr1-scatter-add

Conversation

@robro612

@robro612 robro612 commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Updated after review (thanks @SilvioM97 — see discussion below). The O(n·k) update scan still goes away, but the strategy changes from parallelizing over points to parallelizing over clusters:

  1. A counting sort over assignments (cluster-size histogram → prefix sum → stable permutation of point indices grouped by cluster), O(n), serial.
  2. Cluster-parallel accumulation: par_chunks_mut(d) over one shared output, so each rayon task owns a disjoint set of clusters and gathers its members via the grouped index list. No per-thread accumulators, no reduce step.

This addresses all three review points:

  1. Accumulator memory — scratch is one u32 per point plus O(k) offsets, independent of thread count, instead of n_threads × k × d floats all live at once.
  2. Page-fault traffic — no more first-touching n_threads × k × d fresh zero pages per iteration.
  3. Reproducibility — each cluster's points are summed in a fixed (ascending) order from the stable counting sort, so centroids are bit-identical across thread counts. The seed doc comment's "reproducible across runs and thread counts" now holds, and a new test enforces it.

Branch is rebased onto current main (0.6.1), with the cluster-parallel change as a separate commit on top of the original scatter-add so the delta is easy to review.

Evidence

Both configs below use synthetic data (seeded uniform random values in [-1, 1]).

Scale 1: n=200k, d=128, k=8000, 32 cores

update step point-parallel (original) cluster-parallel (this PR)
f32 67.6 ms 22.9 ms (3.0×)
f16 58.3 ms 15.0 ms (3.9×)
speedup vs O(n·k) brute reference 1.4× 5.5×
max centroid diff vs brute 6.0e-8 6.0e-8

Minor page faults for the whole test process dropped 230k → 129k.

Scale 2: Dragon scale — n=8M, d=768, k=32768, random assignments, 64-core node

Synthetic vectors with the same shape as the MS MARCO Dragon config from the review, not the actual Dragon embeddings. Per-phase peak RSS measured via VmHWM deltas, both implementations in the same process:

threads cluster-parallel peak RSS point-parallel (original) peak RSS formula t·k·d·4
8 593 ms +0.18 GiB 972 ms +0.89 GiB 0.75 GiB
32 385 ms +0.19 GiB 1.56 s +3.10 GiB 3.00 GiB
64 381 ms +0.15 GiB 2.36 s +6.09 GiB 6.00 GiB

Max centroid diff between the two: 1.6e-7.

  • Point-parallel scratch tracks the n_threads × k × d × 4 formula and reproduces the ~6.3 GiB at 64 cores measured in the review; cluster-parallel stays flat at ~0.2 GiB.
  • Point-parallel gets slower as threads increase (0.97 s → 2.36 s from 8 to 64 threads): the page-fault and reduce cost grows with the thread count. Cluster-parallel scales normally and is 6.2× faster at 64 threads.

One measurement note: peak-RSS comparisons of the point-parallel version need random (or real) assignments. With round-robin i % k assignments a worker's contiguous point range touches at most n/threads distinct clusters, so most accumulator pages stay untouched kernel zero pages and never count toward RSS.

Tests

  • update_deterministic_across_thread_counts — centroids bit-identical (f32::to_bits) across 1/2/4/8-thread pools on non-integer random data (integer-valued data like SIFT is exactly representable in f32 and hides reassociation, per the review note).
  • update_weighted_means_match_reference — non-uniform weights vs hand-computed means.
  • Existing update/split/train tests unchanged and passing; full suite: 194 unit + 29 doc tests pass.

@SilvioM97

Copy link
Copy Markdown
Contributor

Hey Rohan, thanks for this! The diagnosis is right and the TODO you removed was a real one. The O(n·k) scan needed to go, keeping f32 accumulation is correct, and the block comment is honest about the tradeoff.

I benchmarked it before commenting, on SIFT1M and MS MARCO Dragon.

One note on the base: this branches off 0.4.0 and main is now at 0.6.1, 16 commits ahead. kmeans.rs is untouched by all of them (Dataset::permute edited 4 lines and the 0.5.1 bump reverted them), so the rebase is clean and the rebased diff is byte-identical to what you submitted. The numbers below are from that rebase, so they apply directly. Just worth a git rebase before merge.

End-to-end k-means: HNSW centroid assignment (M=32, ef_c=200, ef_search=16, k=32768, n_iter=25):

config main this PR speedup
SIFT1M (1M × 128) 44,865 ms 27,005 ms 1.66×
Dragon, 64×k sample (2.1M × 768) 480,847 ms 462,261 ms 1.04×

We're measuring different things, so both sets of numbers are right. Your 176.8 → 41.1 ms is the update step in isolation, and it reproduces cleanly — at d=128 I get 3.6× at k=4096 and 6.5× at k=16384, so 4.3× at k=8192 sits right on that curve. On full Dragon the step alone is 4.1× at k=32768 and 5.0× at k=65536.

The table above is the whole train call. With HNSW assignment the update step is only ~15% of an iteration (≈10.1 s of a ≈69 s iteration on full Dragon), so 4.1× on that part caps the iteration at ~1.12×, and I measure 1.13× e2e on full Dragon with no sampling. The e2e figure is the one that decides whether the memory cost below is worth paying.

A clear win at low dimension, then; ~4% at Dragon's realistic sample size. Three things I'd like to resolve before merging.

1. What the accumulator cost comes to in practice

You document the n_threads * k * d formula, and at your benchmark's config it lands at 16 × 8192 × 128 × 4 = 64 MiB — small enough to be invisible, which is why I think this didn't surface. Production moves all three factors at once: 16 → 64 cores, 8k → 32k clusters, 128 → 768 dims, i.e. 96× the same formula. Since partials is .collect()ed, all 64 accumulators are live simultaneously.

Peak RSS attributable to the step, full Dragon (8.8M × 768), one k per process:

k main this PR
32,768 266 MiB 6302 MiB
65,536 566 MiB 12421 MiB

That's more than a 20x increase, and it grows with core count, where main's k × d accumulator does not. On a 128-core machine k=65536 would want ~24 GiB per iteration.

2. The page-fault traffic isn't free

First-touching those accumulators every call pushes most of the CPU time into the kernel: on full Dragon sys time goes 44% → 66% → 84% as k goes 16k → 32k → 64k, and it sits at ~90% at 1M scale. main stays under 2% at these k.

It also caps the win rather than just costing memory, on SIFT the step-level speedup peaks at 7.5× (k=32768, 28% sys) and falls to 3.0× at k=65536 (90% sys).

3. kmeans.rs:27 still promises the opposite

Your comment correctly says "deterministic for a fixed thread count". The unresolved bit is the doc comment on the seed field, which predates this PR and says the opposite:

Setting a seed makes clustering fully reproducible across runs and thread counts.

Measured on Dragon (1M sample), k=4096: with chunk = n.div_ceil(n_threads) (line 146) the resulting centroids differ at 1, 4, 16 and 64 threads, where main is bit-identical at all four. Either the guarantee or the code needs to move.

Worth knowing SIFT can't catch this: its descriptors are integers, so cluster sums stay exactly representable in f32 and reordering is bit-exact.

A possible direction

All three of the above trace back to one choice: parallelising over points. Because two threads can land in the same cluster, each one needs its own private k × d accumulator, hence the duplication, the first-touch traffic, and the thread-count-dependent summation order.

I think parallelising over clusters could be a good direction instead: if each thread owns a disjoint set of clusters, nothing is shared, one accumulator suffices and there are no partials to reduce. That needs the points grouped by cluster first (a counting sort over assignments) so it trades the per-thread accumulators for an O(n) permutation, and the summation order stops depending on the thread count.

That's a suggestion, happy to talk it through and to to share the benchmark harness if useful!

robro612 and others added 2 commits August 11, 2026 10:13
Replace the O(n*k) mean computation in update_and_split (which re-scanned all n
points once per centroid) with a single O(n*d) scatter-add: each rayon worker
owns a private (sums[k*d], counts[k]) accumulator over a disjoint point range,
then partials are reduced in parallel over disjoint output ranges. The split,
spherical renorm, and return path are unchanged, as is the function signature;
same rayon primitives already in use, no new dependency.

Benefits every caller (IVF build, PQ codebook training); the speedup grows with
k. Measured on synthetic data (n=200k, d=128, k=8k, 16 cores): 176.8ms -> 41.1ms
(4.3x), max centroid diff 1.8e-7 vs the brute-force reference (float
reassociation only).

Tests: update_and_split_scatter_matches_reference_means (equals hand-computed
means) and update_and_split_fires_split_on_empty_cluster (split path intact).
…iant memory and results)

Replace the point-parallel scatter-add with a counting sort over
assignments (histogram -> prefix sum -> stable point-index grouping)
followed by a cluster-parallel accumulation into one shared output.
Still one O(n*d) pass, but scratch drops from n_threads*k*d floats of
per-worker accumulators to one u32 per point, independent of thread
count, and the fixed per-cluster summation order makes centroids
bit-identical across thread counts (as the `seed` docs promise).

Adds a bit-exactness test across 1/2/4/8-thread pools and a weighted
means test; full suite passes (194 unit + 29 doc tests).

Co-authored-by: Cursor <cursoragent@cursor.com>
@robro612 robro612 changed the title kmeans: scatter-add update step (O(n·d) instead of O(n·k)) kmeans: cluster-parallel update via counting sort (O(n·d), thread-count-invariant) Aug 11, 2026
@robro612

robro612 commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review Silvio! You were right that all three issues trace back to parallelizing over points, so I took the direction you suggested: a counting sort groups point indices by cluster, then a cluster-parallel accumulation writes into a single shared output. That drops the scratch to one u32 per point (thread-count-independent), removes the per-iteration zero-page traffic, and fixes the summation order so centroids are bit-identical across thread counts — with a test enforcing it on non-integer data, since as you noted SIFT can't catch it.

I benchmarked at your Dragon config's scale — synthetic uniform vectors with the same shape (8M × 768, k=32768), not the actual Dragon embeddings — on a 64-core node: the cluster-parallel update is 6.2× faster than the point-parallel version with ~0.2 GiB scratch vs ~6.1 GiB, which reproduces your 6302 MiB measurement almost exactly. Full numbers and methodology are in the updated PR description.

The branch is updated in place: rebased onto 0.6.1 as requested, with the cluster-parallel change as a separate commit on top of the original scatter-add so the delta is easy to review. I'd take you up on the benchmark harness offer to confirm the end-to-end numbers on your setup.

@SilvioM97

Copy link
Copy Markdown
Contributor

Hi Rohan, this is great, thanks for turning it around so fast. The cluster-parallel version fixes all three points, and the counting sort reads really cleanly. Approving.

I re-ran the full benchmark suite on both collections. Everything below is total wall clock of the real build, n_iter=10, 64 threads, idle machine, one variant at a time. v1 = your original point-parallel commit, v2 = the cluster-parallel one.

IVF build: k-means (HNSW assignment, M=32/ef_c=200/ef_search=16, k=32768) + final assignment of every vector:

collection main v1 v2 peak (main → v2)
Dragon 8.84M × 768, 64×k sample 268,481 ms 256,239 ms 244,404 ms (1.10×) 6,757 → 6,670 MiB
SIFT1M 1M × 128, no sampling 19,674 ms 12,788 ms 12,149 ms (1.62×) 625 → 604 MiB

PQ build: ProductQuantizer::train (10% sample, M subspaces × 10 k-means iterations) + encoding the collection:

collection main v1 v2
Dragon 1M × 768, M=96 187,013 ms 138,276 ms 138,726 ms (1.35×)
SIFT1M 1M × 128, M=16 27,408 ms 18,987 ms 19,882 ms (1.38×)

So v2 beats main on all four builds, beats v1 on both IVF builds, and does it at main's memory footprint instead of v1's. The determinism test passes on real-valued data too, I confirmed bit-identical centroids at 1/4/16/64 threads on Dragon, where v1 gave four different answers.

pr1_bench.tar.gz attached: both experiment binaries, the runner scripts, and the raw output behind every number above. kANNolo 0.9.0 already pins v0.6.1 so no patching is needed; just repoint the vectorium dependency at a local checkout.

Merging this now. One heads-up: PR #2 is still based on the pre-rebase head of this branch, so it'll need a rebase now that this has landed, and since it changes train_with_index's signature, worth doing before I review it.

@SilvioM97
SilvioM97 merged commit c8c9dd8 into TusKANNy:main Aug 12, 2026
robro612 added a commit to robro612/vectorium that referenced this pull request Aug 21, 2026
Document the finite-range precondition on C (f16/bf16 do not saturate;
unsigned fixed-point zeros negatives), port the per-redo init-RNG offset
into train so n_redo is not a no-op on the flat path, and drop the
ignored bench_kmeans_fixes leftover from TusKANNy#1.
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.

2 participants