014 — a portable KMeans estimator and CuPy-native update paths
This iteration turns the experimental Lloyd functions into a reusable KMeans estimator while keeping the portable path backend-agnostic. It also tests which CuPy-specific primitive should sit behind the future centroid-update hook proposed in 013.
Public estimator surface
The estimator mirrors the dense Lloyd subset of sklearn.cluster.KMeans:
| parameter | supported behavior |
|---|---|
n_clusters |
positive integer |
init |
"k-means++" or "random" |
n_init |
positive integer or "auto"; auto means 1 for k-means++ and 10 for random |
max_iter, tol, verbose, random_state, copy_x |
same public role as scikit-learn |
algorithm |
"lloyd" only |
Scalable k-means++ is not exposed by this class. The old experimental helper remains in the benchmark module so entries 010–013 stay reproducible, but it is no longer part of the package-level API. Sparse input, sample weights, callable or array initializers, and Elkan are intentionally outside this portable implementation.
fit exposes the conventional cluster_centers_, labels_, inertia_, and n_iter_ attributes. predict, transform, score, fit_predict, and fit_transform follow the estimator API. NumPy, PyTorch, CuPy, and strict array-API arrays retain their namespace, device, and float32 or float64 dtype.
The implementation uses shape- and device-based tile budgets rather than backend names. The CUDA schedule incorporates the portable 013 result: argmin plus gather, update tiles of 16384 rows by up to 512 centers, and smaller assignment center tiles for low-dimensional large-k inputs.
Two inline TODOs define the intended acceleration boundary:
- an assignment factory may cache
torch.compileor a CuPy fused top-1 kernel by shape and dtype; - a centroid-update callable may select
torch.index_add_,cupyx.scatter_add, or another native keyed reduction while retaining the one-hot array-API fallback.
Estimator compliance
The complete local suite passes with SCIPY_ARRAY_API=1:
| check | result |
|---|---|
| scikit-learn estimator checks | 60 passed, 17 skipped for unavailable optional backends |
| full local package suite | 79 passed, 20 skipped |
| strict array-API namespace and device checks | passed on array_api_strict CPU and secondary device |
| CUDA smoke and package tests | 58 estimator checks passed; PyTorch and CuPy fit/predict/transform tests passed |
The estimator checks caught portability details that NumPy accepts but the standard does not: out-of-bounds slice stops, row indexing without an explicit second axis, zero-dimensional indices passed to take, and calls with a different namespace after fitting. Fixing those made the low-level initializer and Lloyd path conform to strict array-API indexing.
CuPy update experiment
The new probe_cupy_updates.py compares five ways to accumulate rows by label on the same L4 and full task shapes as 013. Labels are a fixed uniform random draw so every implementation receives identical contention. Times are medians of five warm measurements and cover one complete centroid update.
| update time | k100-d32 | k550-d6 | k3000-d2 |
|---|---|---|---|
| portable current one-hot tiles | 56.95 ms | 60.02 ms | 65.89 ms |
| portable tuned one-hot tiles | 19.20 ms | 17.66 ms | 18.00 ms |
cupyx.scatter_add + bincount |
1.172 ms | 0.350 ms | 0.365 ms |
cupy.add.at + bincount |
1.143 ms | 0.336 ms | 0.336 ms |
fused sum/count RawKernel |
1.064 ms | 0.161 ms | 0.152 ms |
cupyx.scatter_add is 16x to 50x faster than the tuned portable update and 49x to 181x faster than the old tile schedule. It reduces the same structural bottleneck as PyTorch index_add_: work scales with n_samples x n_features, not with a dense membership matrix covering every center tile.
The raw kernel fuses feature sums and counts into one launch. It is only 1.1x faster than scatter_add at 100 clusters, but 2.2x to 2.4x faster at 550 and 3000 clusters. All native variants agree with scatter_add within 8.4e-7 maximum absolute center error; the small differences are expected from non-deterministic floating-point atomic order.
What should become a CuPy fast path
cupyx.scatter_add is the best first backend-specific implementation:
- it is a public, small, auditable operation and already matches NumPy
add.atsemantics for repeated labels; - it captures almost all the measured gain without maintaining CUDA source;
- it composes directly with
cupy.bincountand the existing empty-center policy.
It has important constraints. CuPy 14.2 documents indexed scatter_add only for CUDA atomicAdd types, including float32 but not float64. Out-of-range indices wrap instead of raising. Floating-point atomics are not deterministic. The adapter must therefore validate labels, use this path for supported dtypes only, and fall back to the portable update for float64 unless a separate implementation is validated. cupy.bincount may also synchronize the device, so microbenchmarks must time the complete update rather than infer its cost from asynchronous launches.
cupy.add.at has the same semantics and nearly identical timing, so it adds no useful alternative. The fused RawKernel is worth retaining as a second experiment, not as the first production path: the present probe is float32 and int64-label specific, and production code would need per-dtype kernels, launch caching, bounds guarantees, empty-center tests, and documented atomic non-determinism.
After the update hook lands, assignment becomes the next CuPy target. Neither cupy.fuse nor a standard reduction expresses distance-plus-top-1 without a score tile. A RawKernel or cupyx.jit kernel can stream centers and retain one best label per sample, analogous to cuVS, but it should be attempted only after end-to-end measurements with the inexpensive scatter update show that assignment is again dominant.
Next implementation steps
- Keep the new estimator's default path pure array API.
- Add a private optional centroid-update callable to
lloyd_stepandfit_lloyd; keep it out of the sklearn-compatible constructor. Lock down the contract(X, labels, old_centers) -> updated_centers: empty clusters keep their old center unless the caller requested farthest-point relocation, in which case the native path must still invoke the shared relocation bookkeeping. - Implement a CuPy adapter with
cupyx.scatter_addplusbincountfor float32 and strict label validation. Add agreement, empty-center, and namespace tests. - Implement the matching PyTorch adapter with
index_add_, under the same hook and tests. - Benchmark full fits before considering the fused raw accumulator or a CuPy top-1 assignment kernel.
The result is a stable portable estimator now, with a narrow extension point for backend acceleration later. The CuPy measurements show that this boundary can recover essentially all of the update gap without contaminating the portable implementation with backend branches.
Previous: large-k Lloyd optimization. Next: native backend updates in the estimator. Index: journal.