Add weighted k-means and k-means++ init (stacked on #22) - #21
Conversation
|
@zeyuyang8 Thanks for your contribution! A couple of suggestions: Benchmarks. There aren't any perf numbers yet It'd be great to see (a) before/after throughput for the flattened-grid change on the existing euclid/cosine paths to confirm no regression, and (b) throughput plus a numerical correctness check for the new weighted centroid-update kernel. Possibly splitting the PR. The flattened-grid fix is independent of the new features and has a wider blast radius — it modifies the shared Glad to help benchmark on an H200 if useful. |
The assign (euclid/cosine, non-split) and shared _centroid_update_chunk_kernel used a 2D launch grid (n_tiles, B) with the batch dimension on grid.y, which CUDA caps at 65535. Any problem with B > 65535 fails to launch with 'CUDA: invalid argument'. Flatten to a 1D grid (B * n_tiles,) and decode program_id(0) as b = flat_id // n_tiles, tile = flat_id % n_tiles. grid.x is capped at 2^31-1, so both large-B and large-N launch. The block->(b,tile) linearization is unchanged, so per-program work and atomic contention are identical. Split-D assign kernels keep their 2D launch (they decode a 2D grid). benchmarks/grid_fix/ adds: - bench_grid.py: before/after throughput on the non-split assign + update paths (run on main and this branch, then --compare). - bench_large_b.py: capability check that B > 65535 launches.
dc8e3c8 to
0cd5a15
Compare
…rnel
Rebased onto the flattened-launch-grid fix (now split into its own PR), so
this PR contains only the opt-in feature work:
- Weighted k-means: new Triton kernel triton_centroid_update_sorted_euclid_weighted
+ batch_kmeans_Euclid_weighted, exposed via FlashKMeans.fit(data, weights=...).
Torch-native fallback supports weights too.
- k-means++ init: init={random,scalable-kmeans++,standard-kmeans++}, n_init
best-of-N restarts by per-batch inertia.
The weighted centroid-update kernel streams D in BLOCK_D tiles with masking
(matching _centroid_update_chunk_kernel), so non-power-of-two D (e.g. 80/96/192)
works and large D + wide dtype stays within a bounded per-program footprint.
It uses a quarter of the shared tile budget because it keeps two fp32
[BLOCK_N, BLOCK_D] tiles live at once (features and features*weights).
Tests: tests/test_weighted_centroid.py (pow2/non-pow2/large-D correctness,
ones==unweighted, empty-cluster fallback). benchmarks/weighted/bench_weighted.py
covers correctness + throughput.
0cd5a15 to
ce41377
Compare
scalable_kmeans_pp (K-Means||) reduced its oversampled candidate set to K
centers with the plain non-greedy _kmeanspp_sequential, while the baseline it
is compared against (standard_kmeans_pp) is greedy (sklearn-style local
trials). The weaker reduction occasionally picked two candidates from one
cluster and left another uncovered, so on ~25% of random instances scalable
init cost exceeded 2x standard, with rare 9-10x blow-ups — independent of
dataset size (not a small-sample artifact).
Using the greedy weighted reduction (standard_kmeans_pp with candidate
weights, matching cuml's robust reduction) removes the tail entirely: across
20 seeds x {N=3200, 32000}, max ratio drops from ~10x to 1.2x, 0/20 exceed 2x,
and scalable is now on par with / slightly better than standard on average.
Fixes the deterministic failure of
tests/test_kmeanspp.py::test_scalable_vs_sequential_quality.
|
Thanks for the review — both suggestions done, and digging in surfaced two real bugs I've also fixed. Split. The grid fix is now its own PR, #22 (grid-only diff vs Benchmarks (H100 80GB, fp16). Scripts are in both PRs so they're reproducible. Grid fix — no regression on the non-split euclid/cosine assign + centroid-update paths (
And it's a real bug fix: at Weighted kernel. While benchmarking I found the first version used a single untiled full-D load — it crashed on non-power-of-two D (96/192/…) and was ~48% slower at D=256. Fixed by streaming D in scalable-kmeans++. Its reduction used plain (non-greedy) kmeans++ while the baseline is greedy, so ~25% of random instances blew up (rare 9–10x cost vs standard, independent of dataset size). Switched to the greedy weighted reduction (matching cuML) — across 20 seeds × {N=3200, 32000}, the max ratio drops from ~10x to 1.2x and scalable is on par with / slightly better than standard on average. All numbers are on an H100 — I don't have an H200 handy, but |
Summary
Stacked on #22. Per @andy-yang-1's suggestion, the flattened-launch-grid fix is split into its own PR (#22). This PR contains only the opt-in feature work:
Weighted k-means. New Triton kernel
triton_centroid_update_sorted_euclid_weighted+batch_kmeans_Euclid_weighted, exposed viaFlashKMeans.fit(data, weights=...). Torch-native fallback supports weights too. The kernel streams D inBLOCK_Dtiles with masking (matching_centroid_update_chunk_kernel), so non-power-of-two D works and large D + wide dtype stays within a bounded footprint.k-means++ initialization.
init={random, scalable-kmeans++, standard-kmeans++}(K-Means‖ matches cuML; standard matches scikit-learn),n_initbest-of-N restarts by per-batch inertia.Weighted centroid-update kernel — correctness + throughput (H100 80GB)
benchmarks/weighted/bench_weighted.py+tests/test_weighted_centroid.py(15 cases).Correctness vs a pure-torch weighted-mean reference:
Throughput (weighted vs unweighted centroid update), overhead ≤ 6%:
scalable-kmeans++ quality fix
The scalable (K-Means‖) reduction used a plain non-greedy kmeans++, while the baseline is greedy — so on ~25% of instances scalable cost exceeded 2x standard (rare 9–10x blow-ups). Switching the reduction to the greedy weighted variant (matching cuML) removes the tail: across 20 seeds × {N=3200, 32000}, max ratio 10x → 1.2x, 0/20 exceed 2x. Fixes
test_scalable_vs_sequential_quality.Tests
pytest tests/→ 24 passed (k-means++ init + weighted-kernel correctness incl. non-pow2 D).Note on the diff
Until #22 merges, this PR's diff against
mainalso shows the grid-fix files — same commit as #22. After #22 lands I'll rebase and this shows feature-only.