Repository navigation
Conversation
The same assignment runs through array_api_compat on NumPy, PyTorch, and CuPy. Broadcast, Gram, tiled, and running-argmin schedules are timed beside torch.compile and cupy.fuse, with peak device memory recorded above the inputs. Co-authored-by: Olivier Grisel <olivier.grisel@ensta.org>
The probe, the shape that ran out of memory, and the resized sample, centroid, and feature cases compare eager execution with torch.compile and cupy.fuse. Co-authored-by: Olivier Grisel <olivier.grisel@ensta.org>
Co-authored-by: Olivier Grisel <olivier.grisel@ensta.org>
Co-authored-by: Olivier Grisel <olivier.grisel@ensta.org>
Co-authored-by: Olivier Grisel <olivier.grisel@ensta.org>
Co-authored-by: Olivier Grisel <olivier.grisel@ensta.org>
Co-authored-by: Olivier Grisel <olivier.grisel@ensta.org>
CPU tests stay in this pull request. Rendering and the pr-<number> deployment now come from publish-report.yml on main. Co-authored-by: Olivier Grisel <olivier.grisel@ensta.org>
The save step runs under set -u and expands GITHUB_TOKEN, but Actions does not export that variable. The Pages deploy was succeeding and then the job failed before the accumulated tree was pushed. Co-authored-by: Olivier Grisel <olivier.grisel@ensta.org>
main's workflow still fails while saving the Pages tree. This pull request runs the shared workflow at the current commit, which passes GITHUB_TOKEN into that push. Co-authored-by: Olivier Grisel <olivier.grisel@ensta.org>
The report-site push now receives GITHUB_TOKEN on main, so this workflow uses publish-report.yml from that branch again. Co-authored-by: Olivier Grisel <olivier.grisel@ensta.org>
The assignment tiles the Gram product the way a chunked Lloyd kernel does, and the mean update builds a one-hot for one centroid tile at a time. Both paths stay in the array API and run without torch.compile. Co-authored-by: Olivier Grisel <olivier.grisel@ensta.org>
The journal page lists the new schedules and the tile grid. Timings are not in yet. Co-authored-by: Olivier Grisel <olivier.grisel@ensta.org>
One dense iteration from scikit-learn PR 26 takes 1.53 s on 65536x1024x2048 float32 with 4 threads. The Gram tile sweep is running. Co-authored-by: Olivier Grisel <olivier.grisel@ensta.org>
Steady time stays between 0.92 s and 1.33 s. Extra memory matches one copy of the centroid tile until the full distance matrix is larger. Co-authored-by: Olivier Grisel <olivier.grisel@ensta.org>
Thirty-two centroids is the fastest fused running assignment, 5.98 s and 2.56 MiB. At 64 centroids the distance tile is allocated again and the assignment slows to about 10 s. Co-authored-by: Olivier Grisel <olivier.grisel@ensta.org>
Repeating X*X for every new center took 37.5 s and 4 GiB on 65536x1024x8192. Candidate rows are gathered with take, so the draw stays on the device. Co-authored-by: Olivier Grisel <olivier.grisel@ensta.org>
The 4096 by 1024 Gram tile plus a tiled update is 1.70 s on feature-heavy. On the shared smaller shape the L4 step is 0.071 s and the 4-thread Cython iteration is 1.53 s. Hoisting the sample norm brings k-means++ from 37.5 s to 10.3 s. Co-authored-by: Olivier Grisel <olivier.grisel@ensta.org>
fit_kmeans runs array-API k-means++ and Lloyd until the scaled center shift falls under tolerance. The row norm, inertia, feature variance, and centroid update each walk a tile, so a 1 GiB matrix no longer allocates a second copy of itself. Co-authored-by: Olivier Grisel <olivier.grisel@ensta.org>
On 131072 by 2048 by 256 the tiled fit takes 2.33 s and 152 MiB above the input. The 4-thread Cython init takes 24.2 s. cuML stops earlier and the inertias still agree, with the tolerance gap stated in the report. Co-authored-by: Olivier Grisel <olivier.grisel@ensta.org>
The fit's one-hot update defaults to 4096 sample rows. The driver generates blob and noise matrices and times k-means++, one Lloyd step, and a full fit in a fresh process per library. Co-authored-by: Olivier Grisel <olivier.grisel@ensta.org>
Separated blobs match on inertia. Gaps elsewhere follow the k-means++ draw. On 65536 by 512 by 64 the CUDA fit takes 0.099 s. Two larger shapes are still running. Co-authored-by: Olivier Grisel <olivier.grisel@ensta.org>
On 131072 by 2048 by 256 the CUDA fit takes 1.52 s and 134 MiB. cuML takes 4.00 s and reaches a lower inertia. Stock scikit-learn takes 123 s and peaks 2 GiB above the input. Co-authored-by: Olivier Grisel <olivier.grisel@ensta.org>
Co-authored-by: Olivier Grisel <olivier.grisel@ensta.org>
Co-authored-by: Olivier Grisel <olivier.grisel@ensta.org>
Co-authored-by: Olivier Grisel <olivier.grisel@ensta.org>
Co-authored-by: Olivier Grisel <olivier.grisel@ensta.org>
Co-authored-by: Olivier Grisel <olivier.grisel@ensta.org>
Co-authored-by: Olivier Grisel <olivier.grisel@ensta.org>
Co-authored-by: Olivier Grisel <olivier.grisel@ensta.org>
|
Also, the benchmark cases don't include large k & large d cases. Which is a case I had in my previous position, and I struggled with the speed of k-means at the time. I was clustering embeddings of collaborative filtering models (dim 64 usually) and of texts (dim ~100-1000 depending on the model and if PCA was applied beforehand). |
|
Good catch. I will try to spawn a VM to update my bench repo with consolidated timings. |
I agree. I will also add that to the mix. |
|
FYI, I opened two RAFT PRs that speed up cuML k-means with Expected effect on the 015 tasks, estimated from kernel timings on the L4 (I didn't rebuild cuVS to measure it end to end):
NVIDIA/raft#3176 (open, by an NVIDIA engineer) optimizes the same update step for more than 4 keys. On the 015 tasks it accounts for 18% of cuML's GPU time at |
Co-authored-by: Olivier Grisel <olivier.grisel@ensta.org>
Co-authored-by: Olivier Grisel <olivier.grisel@ensta.org>
Co-authored-by: Olivier Grisel <olivier.grisel@ensta.org>
Co-authored-by: Olivier Grisel <olivier.grisel@ensta.org>
Journal 015 included the host-to-device copy in the cuML timer. Rerun those five tasks with the same preallocated CuPy input as k256-d128 and record the ranking changes. Co-authored-by: Olivier Grisel <olivier.grisel@ensta.org>
|
@cakedev0 the results table in the descriptions to account for the problems you found in the prior benchmarking code. |
|
It matches roughly what my agent measured 👍 |
|
I ran the GPU rows of the task mix on an Intel Arc B580 ( The driver imports
dpnp has no native update in Full fit: greedy k-means++ plus 12 Lloyd iterations
Lloyd only, from the same random rows
What I take from this:
Torch XPU peak extra device memory (from Driver (
|
Co-authored-by: Cursor <cursoragent@cursor.com>
Main replaced the hand-written Markdown converter with pandoc. That covers the fragment links and wrapped list items added on this branch. Keep this branch's source-link checker test alongside the pandoc tests. Co-authored-by: Olivier Grisel <olivier.grisel@ensta.org>
done: KMeans: reply to ogrisel's mention in sklearn#35238 (RFC array API KMeans), with his prototype ogrisel/cursor-sandbox#17 done: Threads: check FrancoisPgm's reply on sklearn#35104 (GridSearchCV 10x-50x slower, callback context pickling) and ogrisel's review of the fix sklearn#35131 done: Threads: answer itamarst on cpython#158901 (contended PyMutex collapses with a few tens of threads) done: Array API: answer lorentzenchr and ogrisel on sklearn#35060 (Ridge `solver="cholesky"` for array API) done: Upstream: answer syakov-intel on sklearnex#3401 (RF `predict_proba` rows don't sum to 1) done: GPU: check the red CI of raft#3194 and raft#3195 (small-nkeys kernel of `reduce_rows_by_key`) done: Maintenance: answer AnneBeyer on sklearn#34554 (approved; recursion PDP with categorical features) done: Maintenance: follow up on sklearn#35252 (1.10 deprecations): update my tracking list with the new claims (blank-0007: `entropy`, AnneBeyer: curve displays, FrancoisPgm: MDS and HDBSCAN) done: Preprocessing: answer jeremiedbb on sklearn#34858 (QuantileTransformer/RobustScaler densify sparse columns, see sklearn#34857 and sklearn#34298; since 2026-10-05) done: Upstream: answer david-cortes-intel on oneDAL#3820 (cross_entropy_loss changes between calls with 4+ threads; since 2026-09-30) done: Threads: answer itamarst on joblib#1869 (loky workers' thread count vs physical cores; since 2026-10-01) done: Maintenance: answer StefanieSenger on sklearn#35047 (BaggingClassifier with feature sub-sampling and routed `X_val`; since 2026-10-02) done: Benchmarks: answer lucascolley on sklearn#34865 (Meson downgrades -O3 to -O2 under conda-forge CFLAGS; since 2026-09-04) done: Threads: review sklearn#34914 (new parallelism API with lower overhead; ogrisel commented) done: Threads: review sklearn#34729 (test the thread safety contract of inference methods; waiting on itamarst) done: Preprocessing: review sklearn#35079 (faster `_weighted_percentiles` with several percentiles; antoinebaker mentioned me) done: HGB: review sklearn#34885 (sample_weight zero-weight and integer equivalence; waiting on ogrisel) done: Trees: review sklearn#35151 (warm_start with categorical features in forests, review requested; waiting on adam2392) done: Trees: review sklearn#34809 (export trees as dictionaries, categorical splits; lcrmorin updated it) done: Linear: review sklearn#35194 (per-coefficient positive constraint in Ridge; FrancoisPgm updated it) done: Linear: review sklearn#35026 (normalized BayesianRidge `scores_`; albertcthomas commented) done: Benchmarks: review benchmarks#142 (cuML configuration; ogrisel answered my requested changes) done: Maintenance: review sklearn#35186 (faster Euclidean and Manhattan distance metrics; waiting on itamarst) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main results
The non-cuML cells are the estimator benchmark in journal 015. The cuML cells for those five tasks are journal 021: the same fits, with the training data and the shared random-row centers already on the device before the timer starts. The
k256-d128column, including its cuML cells, is journal 020: 150,000 × 128 samples and 256 clusters, chosen so bothkanddare substantial while stock scikit-learn still finishes (6.60 s on the calibration fit). Every row in the L4 tables ran on one host: 4 CPU threads, one NVIDIA L4, float32, TF32 off, three seeds,max_iter=12,tol=1e-8, and a 60-second worker limit. The other tasks arek3-d1000(950,000 × 1000),k17-d180(2,100,000 × 180),k100-d32(1,500,000 × 32),k550-d6(525,000 × 6), andk3000-d2(135,000 × 2).Two array-API settings share the same estimator and the same arrays:
index_add_+bincount, or CuPyadd.at+bincount. On CUDA the estimator keeps the portable update when the input is wide andkis small (16k < dfor PyTorch,64k < dfor CuPy). In this mix that is onlyk3-d1000, so the two CUDA rows there execute the same update.k256-d128uses the native update on both CUDA backends.use_native_backends(False): the portable one-hot update on every namespace, including PyTorch and CuPy.Array-API rows call
KMeans.fiton data that is already on the device. Stock scikit-learn is 1.7.2. The cakedev0 build is scikit-learn 1.10.dev0 with PR 23 and PR 26. cuML is 26.10. Every cuML cell below uses the same placement as the array-API GPU rows: samples and shared centers are CuPy arrays before timing, andoutput_type="cupy"keeps the fitted centers on the device until after the timed region. Journal 015's cuML numbers included the host-to-device copy; journal 021 replaces those five tasks, and journal 020 already used the device-resident protocol fork256-d128. Values are seed means. In both timing tables the CPU rows come first (Torch CPU, stock scikit-learn, cakedev0), then the GPU rows (Torch CUDA, CuPy, cuML). Bold marks the fastest time on that task inside its hardware group. Tied minima are both bold.Full fit: greedy k-means++ plus 12 Lloyd iterations
Three published means in the original non-cuML columns include one slow seed. Reruns of those cells: Torch CPU
k3-d1000is 16.5 s, and the native-over-generic ratio there is 1.10×; CuPyk3-d1000is 1.11 s once the kernel cache is warm (the 2.53 s mean is a one-time compile, and that shape uses the portable update anyway). Thek256-d128CuPy and cuML seed-0 cells are likewise warm reruns; the first process paid 10.2 s and 3.6 s of one-time setup.cuML scalable on
k3-d1000stops after 2 iterations on seed 1 (1.31 s). A repeat of that seed stops after 2 iterations again (1.30 s). Seeds 0 and 2 run all 12 iterations at 4.20 s and 4.21 s. The 3.24 s mean includes the early stop, as the journal 015 mean of 6.38 ± 1.67 s did.Lloyd only, from the same random rows
This section removes the initializer. Stock scikit-learn and cakedev0 take the same time: cakedev0’s full-fit lead is k-means++, from PR 23, rather than the chunked Lloyd kernel from PR 26. On
k256-d128the two CPU builds tie at 0.50 s.The Torch CPU
k100-d32native mean is one noisy seed (18 s against 4.8 s). A rerun of that seed is 4.8 s, and the native-over-generic ratio is then 1.78×.CPU
The native update is a large win where many clusters make the one-hot product expensive, and a small one where assignment dominates.
k256-d128it is 1.37× (2.03 s against 2.77 s), and the full fit is 1.14× (5.81 s against 6.61 s). The 3000-cluster k-means++ fit finishes in 40 s with the native update and exceeds 60 s with the portable update.k3-d1000). Onk256-d128the native fit is faster: 5.81 s against 6.61 s. Shared-start Lloyd stays slower on every task, including 2.03 s against 0.50 s atk256-d128; the original five are about 2.1×, 2.3×, 3.3×, 3.4×, and 13.5× slower than both scikit-learn builds.k256-d128(5.81 s against 1.66 s). Greedy k-means++ does not use the centroid update, and that is where cakedev0 pulls away on the original tasks: Torch CPU init takes 2.20 / 3.62 / 12.73 / 13.79 / 21.54 s, stock scikit-learn 5.14 / 7.65 / 11.45 / 15.34 / 28.06 s, and cakedev0 1.30 / 2.04 / 1.53 / 1.96 / 3.00 s. The portable initializer already beats stock scikit-learn on four of those five tasks and is 1.7–8× slower than cakedev0. Onk256-d128the Lloyd kernels of the two scikit-learn builds tie, so the full-fit gap is again initialization.GPU
On CUDA the native update adds a modest factor on top of a GEMM assignment, from 17 clusters up on CuPy and from 100 clusters up on PyTorch: shared-start speedups are 1.17–1.22× for Torch CUDA and 1.29–1.47× for CuPy on the original tasks, and 1.07× and 1.12× on
k256-d128. Below that crossover the portable update is as fast, which is whyk3-d1000stays on it.k256-d128(0.49 s against 6.61 s). Warm CuPy is 8–11× faster than stock scikit-learn on the original fits and 14× faster onk256-d128.k256-d128(0.49 s against 1.66 s). Warm CuPy is 6.7×, 5.5×, 2.7×, 1.8×, and 1.3× faster on the original tasks, and 3.5× faster onk256-d128.k256-d128the copy was already excluded. Native CuPy is the fastest greedy full fit, at 0.48 s against 0.49 s for native Torch CUDA and 0.51 s for cuML greedy. Shared-start Lloyd goes the other way: cuML takes 0.12 s, native CuPy 0.19 s, and native Torch CUDA 0.22 s. cuML scalable is the fastest full fit at 0.24 s and ends 0.66% above the best greedy inertia.Greedy k-means++ on Torch CUDA is within about 1.7× of cuML’s greedy initializer on every original task (faster at 17 clusters: 0.36 s against 0.40 s). Those initializer timings already started from device-resident data in journal 015, so they are unchanged. CuPy matches that at 3 and 17 clusters and falls behind as
kgrows (2.93 s against 0.92 s at 3000 clusters). At 100 clusters the full-fit gap is both pieces: CuPy initialization is 0.74 s against 0.48 s, and CuPy Lloyd is 0.39 s against 0.20 s, so the full fit is 1.09 s against 0.68 s.Quality and memory
Greedy full fits on the original tasks land within about 0.4% of the best mean inertia. Shared-start Lloyd agrees within 0.07%, except cuML at 3000 clusters, which keeps empty centers and finishes about 7% higher than the other implementations. Native and generic inertias match at the reported precision. On
k256-d128, greedy full fits stay between 118.919 and 118.930 inertia per point, and shared-start Lloyd agrees within 0.0005 around 118.960. cuML scalable finishes at 119.708, 0.66% higher. On the original tasks, device-resident cuML scalable takes 0.83 / 0.44 / 0.51 / 1.06 s from 17 clusters up and ends 5.4% above its own greedy inertia at 3000 clusters.On
k3-d1000the array-API full fit peaks about 0.94 GiB above the input. Stock scikit-learn and cakedev0 peak about 7.1 GiB. Journal 015 recorded about 3.8 GiB of nvidia-smi growth for cuML there, which included allocating the device copy of the 3.54 GiB input. With that copy done before timing, the same greedy fit grows nvidia-smi by 54 MiB. The other device-resident greedy increments are 136 MiB, 98 MiB, 56 MiB, and 14 MiB. The native update removes the one-hot tile and saves 6–10 MiB at 3000 clusters; elsewhere the assignment tiles dominate. Thek256-d128input is about 73 MiB. Array-API full fits peak about 147 MiB above their allocator baseline, and both scikit-learn builds peak about 146 MiB aboveVmHWM.Arc B390
Journal 022 repeats this mix on a laptop with an Intel Arc B390, and journal 023 adds dpnp on that GPU. These rows are a different host from the L4 tables above: Intel Core Ultra X7 358H, 30 GiB RAM, integrated Arc B390. Float32, PyTorch matmul precision
highest, three seeds,max_iter=12,tol=1e-8. CPU rows use 4 threads pinned to CPUs 0–3. XPU and dpnp workers are unpinned. Stock scikit-learn here is 1.9.1. cakedev0 is the same commit as above. On XPU, native isindex_add_+bincountat every shape, and generic forces the portable one-hot update. dpnp has no keyed reduction, so native and generic execute that same portable update and the table has one dpnp row. Bold is the fastest time in the CPU group and in the GPU group.Full fit: greedy k-means++ plus 12 Lloyd iterations
Lloyd only, from the same random rows
Later, still experimental, backend-neutral changes
Journal 019 reran the 018 prototypes on Torch CPU, Torch CUDA, and CuPy. Those changes are an augmented
[X, 1]GEMM and a labels-only assignment. They sit on top of the native update, and that run does not include stock scikit-learn, cuML, ork256-d128.Augmented GEMM speeds Lloyd when
k >= d: 1.25–3.15× on CPU, 1.37–1.79× on Torch CUDA, and 1.18–1.38× on CuPy, and is neutral below that. It stores another input-sized array, raising thek3-d1000full-fit peak from about 0.95 GiB to 4.5 GiB. After these changes the best eager CPU method is still 2.1–7.0× slower than cakedev0 on init, 2.0–4.3× on Lloyd, and 2.1–6.1× on full fit (the pre-018 Lloyd gap at 3000 clusters was 13.5×). The best Torch CUDA full fit in that run is 2.9–10× faster than cakedev0, and the best CuPy fit is 1.4–8× faster. Augmented GEMM is not the estimator default: it needs a memory budget and the cancellation repair measured in journal 017.Journal 016 found that the GEMM distance wins every Lloyd and full-fit cell against a bounded broadcast, which raised CUDA peak memory by up to 3.9×. Journal 017 found that a translation of
1e3makes plain float32 GEMM mislabel about 97% of rows; cakedev0’s relative fallback restores the labels, at a 1.11–1.91× cost on ordinary data.What the branch adds
KMeansestimator inkmeans_lloyd/src/kmeans_lloyd/kmeans.py.fitis the entry point. It keeps NumPy, PyTorch, CuPy, and strict array-API namespaces and devices, with random initialization and Lloyd iterations.kmeans_plusplusinkmeans_lloyd/src/kmeans_lloyd/iteration.py.k-versus-dcutoff above.xp.isfinite(X)allocated 5.3 GiB of PyTorch temporaries on the 3.8 GB input.k256-d128._worker_cumlstarts the cuML timer after the training data and shared centers are resident CuPy arrays. Journal 021 applies that contract to the five tasks whose cuML cells in journal 015 included the copy.Journal
Rendered index: https://ogrisel.github.io/cursor-sandbox/pr-17/
k256-d128, with device-resident cuML input