Clustering
C++20 header-only: DBSCAN, HDBSCAN, k-means.
Loading...
Searching...
No Matches
lloyd_fused_gemm.h
Go to the documentation of this file.
1#pragma once
2
3#include <citor/cancellation.h>
4
5#include <algorithm>
6#include <array>
7#include <cmath>
8#include <cstddef>
9#include <cstdint>
10#include <cstring>
11#include <limits>
12#include <type_traits>
13#include <utility>
14
16#include "clustering/kmeans/detail/convergence.h"
17#include "clustering/kmeans/detail/empty_cluster.h"
21#include "clustering/math/detail/avx2_helpers.h"
22#include "clustering/math/detail/columnwise_reduce_avx2.h"
23#include "clustering/math/detail/gemm_outer_prepacked.h"
24#include "clustering/math/detail/gemm_pack.h"
25#include "clustering/math/detail/matrix_desc.h"
26#include "clustering/math/detail/pairwise_argmin_outer.h"
31#include "clustering/ndarray.h"
32
33namespace clustering::kmeans {
34
35namespace detail {
36
47struct BlockPartition {
48 std::size_t first_index = 0;
49 std::size_t span = 0;
50 std::size_t num_blocks = 0;
51
52 BlockPartition(std::size_t first, std::size_t n, std::size_t desired) noexcept
53 : first_index(first), span(n) {
54 if (n == 0 || desired == 0) {
55 num_blocks = 0;
56 span = 0;
57 return;
58 }
59 num_blocks = std::min(desired, n);
60 }
61
62 [[nodiscard]] std::size_t blockIndexOf(std::size_t lo) const noexcept {
63 if (num_blocks == 0 || span == 0) {
64 return 0;
65 }
66 // Invert the citor-style partition `[first + span*s/P, first + span*(s+1)/P)` using
67 // `ceil((rel + 1) * P / span) - 1`, so left boundaries map to their own slot.
68 const std::size_t rel = lo - first_index;
69 const std::size_t s = (((rel + 1) * num_blocks) - 1) / span;
70 return s >= num_blocks ? num_blocks - 1 : s;
71 }
72};
73
82inline constexpr std::size_t kDirectArgminMaxD = 8;
83
84} // namespace detail
85
99template <class T> class LloydFusedGemm {
100public:
101 static_assert(std::is_same_v<T, float> || std::is_same_v<T, double>,
102 "LloydFusedGemm<T> requires T to be float or double");
103
105 : m_centroidsOld({0, 0}), m_cSqNorms({0}), m_sums({0, 0}), m_counts({0}), m_minDistSq({0}),
106 m_shiftSq({0}), m_partialSums({0}), m_partialComps({0}), m_partialCounts({0}),
107 m_foldComp({0}), m_packedB({0}), m_packedCSqNorms({0}), m_distsChunk({0, 0}),
108 m_gemmApArena({0}), m_packedXAp({0}), m_xNormsSq({0}), m_varSum({0}), m_varSumSq({0}),
109 m_varPartialSum({0}), m_varPartialSumSq({0}), m_u({0}), m_l({0}), m_shiftEuclidean({0}),
110 m_halfDistToNearestOther({0}), m_elkanBounds({0, 0}), m_centerDist({0, 0}) {}
111
112#ifdef CLUSTERING_KMEANS_KAHAN_N_THRESHOLD
121 static constexpr std::size_t kahanNThreshold = CLUSTERING_KMEANS_KAHAN_N_THRESHOLD;
122#else
124 static constexpr std::size_t kahanNThreshold = 100000;
125#endif
126
148 std::size_t k, std::size_t maxIter, T tol, math::Pool pool,
149 NDArray<std::int32_t, 1> &outLabels, double &outInertia, std::size_t &outNIter,
150 bool &outConverged) {
151 const std::size_t n = X.dim(0);
152 const std::size_t d = X.dim(1);
153
156 CLUSTERING_ALWAYS_ASSERT(centroids.dim(0) == k);
157 CLUSTERING_ALWAYS_ASSERT(centroids.dim(1) == d);
158 CLUSTERING_ALWAYS_ASSERT(outLabels.dim(0) == n);
159
160 if (n == 0 || d == 0) {
161 outNIter = 0;
162 outConverged = true;
163 outInertia = 0.0;
164 return;
165 }
166
167 const std::size_t workerCount = pool.workerCount();
168 ensureShape(n, d, k, workerCount);
169
170 // Sklearn-compatible tol semantics: the threshold on sum(||deltac_j||^2) is @c tol * mean_var
171 // where @c mean_var is the mean of per-column variances of @p X. This is scale-invariant,
172 // which is the property callers expect when they pass the same numeric @c tol across
173 // datasets of different magnitudes. computeXStatistics derives mean_var and the per-row
174 // squared norms in one pass over @p X so the two O(n*d) sweeps share a single fan-out.
175 // Cache against (X.data(), n, d): the best-of harness calls run() n_init times with the same
176 // X, so the recompute is pure overhead once the data pointer and shape match.
177 T meanVar;
178 if (m_xstatsCachedXData == X.data() && m_xstatsCachedN == n && m_xstatsCachedD == d) {
179 meanVar = m_xstatsCachedMeanVar;
180 } else {
181 meanVar = computeXStatistics(X, pool);
182 m_xstatsCachedXData = X.data();
183 m_xstatsCachedN = n;
184 m_xstatsCachedD = d;
185 m_xstatsCachedMeanVar = meanVar;
186 }
187 const T shiftSqThreshold = tol * meanVar;
188 const bool useKahan = n >= kahanNThreshold;
189
190 refreshCentroidSqNorms(centroids);
191
192 std::size_t iter = 0;
193 bool converged = false;
194
195 // Hamerly pruning always runs above the direct small-D path. Fused-argmin shapes seed
196 // valid per-point bounds after the first dense assignment; chunked shapes seed them inline
197 // during the argmin post-pass; direct shapes seed them in a post-pass over the first dense
198 // assignment and join only when the scan volume and pool width keep the pruning ahead of
199 // the dense tile kernel (see @ref kHamerlyMinDirectScanDims, @ref kHamerlyDirectWorkerCap).
200 // @c k is capped by @c kHamerlyMaxK because the per-row scan uses a stack-allocated
201 // distance buffer; above that, Elkan handles bounded shapes and the rest fall back to
202 // unbounded assignment.
203 const bool directHamerly =
204 (d * k >= kHamerlyMinDirectScanDims) && (workerCount <= kHamerlyDirectWorkerCap);
205 const bool hamerlyEligible =
206 ((d > detail::kDirectArgminMaxD) || directHamerly) && (k <= kHamerlyMaxK) && (k >= 2);
207 // Elkan keeps k lower bounds per sample instead of Hamerly's one, pruning far more distance
208 // work once k exceeds Hamerly's regime. The @c n * k bound matrix grows linearly in both,
209 // so we gate on an @c n * k envelope bound (memory ceiling) and require @c k above the
210 // Hamerly cap so the two paths don't overlap.
211 const bool elkanEligible = (d > math::defaults::pairwiseArgminMaxD) && (k > kHamerlyMaxK) &&
212 (k <= kElkanMaxK) && (n * k <= kElkanNKLimit) && (k >= 2);
213
214 bool ranPlex = false;
215#ifdef CLUSTERING_USE_AVX2
216 if constexpr (std::is_same_v<T, float>) {
217 // The direct/fused assignment family runs every iteration inside one persistent-worker
218 // plex: workers stay spin-resident across phases and the serial glue rides the
219 // pre-phase hook, dropping the per-iteration fork/join. Chunked shapes join under
220 // Hamerly: phase 0 carries the full chunked GEMM assignment, later phases the
221 // bounds-aware row scan. Elkan and non-Hamerly chunked shapes keep the per-iteration
222 // dispatch below, as do iterations too small to amortize the per-phase epoch cost,
223 // where the dispatcher's small-work gates win.
224 constexpr std::size_t kMinPlexElems = std::size_t{1} << 16;
225 const bool plexChunkedHamerly = hamerlyEligible && d > math::defaults::pairwiseArgminMaxD;
226 if (pool.pool != nullptr && workerCount > 1 && maxIter > 0 && (n * d >= kMinPlexElems) &&
227 (assignmentProducesDirectMinDistSq(X, centroids) ||
228 assignmentUsesFusedArgmin(X, centroids) || plexChunkedHamerly)) {
229 runPlexLoop(X, centroids, outLabels, k, maxIter, shiftSqThreshold, useKahan,
230 hamerlyEligible, pool, iter, converged);
231 ranPlex = true;
232 }
233 }
234#endif
235
236 while (!ranPlex && iter < maxIter) {
237 std::memcpy(m_centroidsOld.data(), centroids.data(),
238 centroids.dim(0) * centroids.dim(1) * sizeof(T));
239
240 if (hamerlyEligible && iter > 0) {
241 runHamerlyAssignmentAndScatter(X, centroids, outLabels, k, useKahan, pool);
242 } else if (elkanEligible && iter > 0) {
243 runElkanAssignmentAndScatter(X, centroids, outLabels, k, useKahan, pool);
244 } else {
245 runAssignmentAndScatter(X, centroids, outLabels, k, useKahan, pool);
246 if (hamerlyEligible && iter == 0 && assignmentUsesFusedArgmin(X, centroids)) {
247 seedHamerlyBoundsFromAssignedMinDist(outLabels, k, d, pool);
248 }
249 }
250
251 (void)::clustering::kmeans::detail::reseedEmptyClusters<T>(X, centroids, m_sums, m_counts,
252 m_minDistSq);
253 finalizeMeans(centroids);
254 refreshCentroidSqNorms(centroids);
255
256 math::centroidShift<T>(m_centroidsOld, centroids, m_shiftSq, pool);
257 const T totalShift = ::clustering::kmeans::detail::totalShiftSqKahan<T>(m_shiftSq);
258
259 ++iter;
260 if (totalShift <= shiftSqThreshold) {
261 converged = true;
262 break;
263 }
264 }
265
266 // Re-assign labels against the final centroids. Hamerly can refresh exact assigned
267 // distances while proving labels, so final inertia can consume m_minDistSq directly.
268 bool finalMinDistExact = assignmentProducesDirectMinDistSq(X, centroids);
269 if (hamerlyEligible && iter > 0) {
270 runHamerlyAssignment(X, centroids, outLabels, pool, true);
271 finalMinDistExact = true;
272 } else if (elkanEligible && iter > 0) {
273 runElkanAssignment(X, centroids, outLabels, math::Pool{});
274 } else {
275 runAssignment(X, centroids, outLabels, pool);
276 }
277 if (!finalMinDistExact) {
278 recomputeMinDistSqDirect(X, centroids, outLabels, pool);
279 }
280
281 outInertia = inertiaKahan(n, pool);
282 outNIter = iter;
283 outConverged = converged;
284 }
285
286private:
289 struct HamerlyShiftTop2 {
290 T sMax = T{0};
291 T s2Max = T{0};
292 std::size_t argMax = 0;
293 };
294
297 [[gnu::always_inline]] void scatterRowToSlab(const T *xBase, const std::int32_t *labelsBase,
298 std::size_t i, std::size_t slot, std::size_t k,
299 std::size_t d, bool useKahan) noexcept {
300 const std::int32_t lbl = labelsBase[i];
301 if (lbl < 0 || std::cmp_greater_equal(lbl, k)) {
302 return;
303 }
304 const auto row = static_cast<std::size_t>(lbl);
305 const T *xRow = xBase + (i * d);
306 T *sumRow = m_partialSums.data() + (((slot * k) + row) * d);
307 std::int32_t *cslab = m_partialCounts.data() + (slot * k);
308 if (useKahan) {
309 T *compRow = m_partialComps.data() + (((slot * k) + row) * d);
310 math::detail::kahanAddRow<T>(xRow, d, sumRow, compRow);
311 } else {
312 for (std::size_t t = 0; t < d; ++t) {
313 sumRow[t] += xRow[t];
314 }
315 }
316 cslab[row] += 1;
317 }
318
327 [[nodiscard]] T computeXStatistics(const NDArray<T, 2, Layout::Contig> &X, math::Pool pool) {
328 const std::size_t n = X.dim(0);
329 const std::size_t d = X.dim(1);
330 if (n == 0 || d == 0) {
331 return T{0};
332 }
333 const T *xData = X.data();
334
335 if (m_varSum.dim(0) != d) {
336 m_varSum = NDArray<T, 1>({d});
337 m_varSumSq = NDArray<T, 1>({d});
338 }
339 T *colSum = m_varSum.data();
340 T *colSumSq = m_varSumSq.data();
341
342 const std::size_t workers = pool.workerCount();
343 const bool willParallelize = workers > 1;
344 if (willParallelize) {
345 const std::size_t partialSize = workers * d;
346 if (m_varPartialSum.dim(0) != partialSize) {
347 m_varPartialSum = NDArray<T, 1>({partialSize});
348 m_varPartialSumSq = NDArray<T, 1>({partialSize});
349 }
350 T *partialSum = m_varPartialSum.data();
351 T *partialSumSq = m_varPartialSumSq.data();
352 for (std::size_t e = 0; e < partialSize; ++e) {
353 partialSum[e] = T{0};
354 partialSumSq[e] = T{0};
355 }
356 pool.parallelForExactBlocksWithSlot<citor::HintsDefaults>(
357 std::size_t{0}, n, workers,
358 [&](std::size_t lo, std::size_t hi, std::size_t slot) noexcept {
359 T *localSum = partialSum + (slot * d);
360 T *localSumSq = partialSumSq + (slot * d);
361 for (std::size_t i = lo; i < hi; ++i) {
362 const T *row = xData + (i * d);
363 math::detail::columnwiseAccumSumSq<T>(row, d, localSum, localSumSq);
364 m_xNormsSq(i) = math::detail::sqNormRow<T, Layout::Contig>(X, i);
365 }
366 });
367 for (std::size_t t = 0; t < d; ++t) {
368 T s = T{0};
369 T ss = T{0};
370 for (std::size_t w = 0; w < workers; ++w) {
371 s += partialSum[(w * d) + t];
372 ss += partialSumSq[(w * d) + t];
373 }
374 colSum[t] = s;
375 colSumSq[t] = ss;
376 }
377 } else {
378 for (std::size_t t = 0; t < d; ++t) {
379 colSum[t] = T{0};
380 colSumSq[t] = T{0};
381 }
382 for (std::size_t i = 0; i < n; ++i) {
383 const T *row = xData + (i * d);
384 math::detail::columnwiseAccumSumSq<T>(row, d, colSum, colSumSq);
385 m_xNormsSq(i) = math::detail::sqNormRow<T, Layout::Contig>(X, i);
386 }
387 }
388
389 const auto nInv = static_cast<T>(1) / static_cast<T>(n);
390 T acc = T{0};
391 for (std::size_t t = 0; t < d; ++t) {
392 const T mean = colSum[t] * nInv;
393 acc += (colSumSq[t] * nInv) - (mean * mean);
394 }
395 return acc / static_cast<T>(d);
396 }
397
398 [[nodiscard]] double inertiaKahan(std::size_t n, math::Pool pool) {
399 const T *minDist = m_minDistSq.data();
400 auto sumRange = [minDist](std::size_t lo, std::size_t hi) noexcept {
401 double sum = 0.0;
402 double comp = 0.0;
403 for (std::size_t i = lo; i < hi; ++i) {
404 const auto addend = static_cast<double>(minDist[i]);
405 const double y = addend - comp;
406 const double t = sum + y;
407 comp = (t - sum) - y;
408 sum = t;
409 }
410 return sum;
411 };
412 return pool.parallelReduce<citor::HintsDefaults>(
413 std::size_t{0}, n, 0.0, sumRange,
414 [](double lhs, double rhs) noexcept { return lhs + rhs; });
415 }
416
417 [[nodiscard]] static constexpr bool
418 packedXApCacheEnabledForShape(std::size_t n, std::size_t d, std::size_t workerCount) noexcept {
419 if constexpr (std::is_same_v<T, float>) {
420 constexpr std::size_t kPackedXMaxElements = std::size_t{4} << 20;
421 constexpr std::size_t kPackedXMaxWorkers = 4;
422 return workerCount <= kPackedXMaxWorkers && d > math::defaults::pairwiseArgminMaxD &&
423 d != 0 && n <= (kPackedXMaxElements / d);
424 } else {
425 (void)n;
426 (void)d;
427 (void)workerCount;
428 return false;
429 }
430 }
431
432 void resetPackedXApCacheMetadata() noexcept {
433 m_packedXApCachedXData = nullptr;
434 m_packedXApCachedN = 0;
435 m_packedXApCachedD = 0;
436 }
437
438 void ensureShape(std::size_t n, std::size_t d, std::size_t k, std::size_t workerCount) {
439 const bool shapeChanged = (n != m_n) || (d != m_d) || (k != m_k);
440 const bool workerChanged = (workerCount != m_workerCount);
441 if (!shapeChanged && !workerChanged) {
442 return;
443 }
444
445 const bool needsChunk = d > math::defaults::pairwiseArgminMaxD;
446 const std::size_t chunkCap = math::pairwiseArgminChunkRows;
447 const std::size_t blocks = workerCount == 0 ? std::size_t{1} : workerCount;
448 const bool directHamerlyScratch =
449 (d * k >= kHamerlyMinDirectScanDims) && (blocks <= kHamerlyDirectWorkerCap);
450 const bool hamerlyScratch = ((d > detail::kDirectArgminMaxD) || directHamerlyScratch) &&
451 (k <= kHamerlyMaxK) && (k >= 2);
452 const std::size_t partialBlocks = hamerlyScratch ? hamerlyScatterBlocks(n, blocks) : blocks;
453
454 if (shapeChanged) {
455 m_centroidsOld = NDArray<T, 2, Layout::Contig>({k, d});
456 m_cSqNorms = NDArray<T, 1>({k});
457 m_sums = NDArray<T, 2, Layout::Contig>({k, d});
458 m_counts = NDArray<std::int32_t, 1>({k});
459 m_minDistSq = NDArray<T, 1>({n});
460 m_xNormsSq = NDArray<T, 1>({n});
461 m_shiftSq = NDArray<T, 1>({k});
462 m_foldComp = NDArray<T, 1>({k * d});
463 // Hamerly bound scratch: per-point upper/lower Euclidean bounds and per-cluster sqrt
464 // shifts. Seeded by the first iteration's full scan and maintained by the bounds-aware
465 // reassignment on subsequent iterations.
466 m_u = NDArray<T, 1>({n});
467 m_l = NDArray<T, 1>({n});
468 m_shiftEuclidean = NDArray<T, 1>({k});
469 m_halfDistToNearestOther = NDArray<T, 1>({k});
470 // Elkan's @c n * k bound matrix and the per-pair centroid-distance matrix are only
471 // touched when @c k > @ref kHamerlyMaxK (Hamerly handles smaller k). Skip the n*k
472 // allocation entirely at small k -- the n*k slab dominates per-call
473 // alloc cost when KMeans is constructed fresh per binding call.
474 const bool elkanCanFire = (k > kHamerlyMaxK) && (k <= kElkanMaxK) && (n * k <= kElkanNKLimit);
475 if (elkanCanFire) {
476 m_elkanBounds = NDArray<T, 2, Layout::Contig>({n, k});
477 m_centerDist = NDArray<T, 2, Layout::Contig>({k, k});
478 } else {
479 m_elkanBounds = NDArray<T, 2, Layout::Contig>({0, 0});
480 m_centerDist = NDArray<T, 2, Layout::Contig>({0, 0});
481 }
482 // Packed-B sizing: the fused fast path at d<=pairwiseArgminMaxD uses the flat
483 // panel-per-centroid layout (ceil(k/Nr)*Nr*d); the chunked fallback uses the tiled
484 // (jcIdx, pcIdx) layout that @c gemmRunPrepacked expects and is what supports d > kKc
485 // and k > kNc without envelope asserts.
486 const std::size_t packedBSize = needsChunk
487 ? math::detail::packedBScratchSizeFloatsTiled<T>(k, d)
488 : math::detail::packedBScratchSizeFloats(k, d);
489 const std::size_t packedNormsSize = math::detail::packedCSqNormsScratchSizeFloats(k);
490 m_packedB = NDArray<T, 1>({packedBSize == 0 ? std::size_t{1} : packedBSize});
491 m_packedCSqNorms = NDArray<T, 1>({packedNormsSize == 0 ? std::size_t{1} : packedNormsSize});
492 // Per-worker distance tile for the chunked path: one chunkCap*k slab per worker so
493 // the chunk fan-out runs without touching a shared tile.
494 const std::size_t distRows = needsChunk ? (blocks * chunkCap) : std::size_t{1};
495 const std::size_t safeK = (k == 0) ? std::size_t{1} : k;
496 const std::size_t distCols = needsChunk ? safeK : std::size_t{1};
497 m_distsChunk = NDArray<T, 2, Layout::Contig>({distRows, distCols});
498 } else if (workerChanged) {
499 // Only the per-worker slabs depend on workerCount; resize them if d triggered needsChunk.
500 if (needsChunk) {
501 const std::size_t distRows = blocks * chunkCap;
502 const std::size_t distCols = (k == 0 ? std::size_t{1} : k);
503 m_distsChunk = NDArray<T, 2, Layout::Contig>({distRows, distCols});
504 }
505 }
506
507 // Per-block scratch sizing for scatter-and-fold. Hamerly may use oversubscribed deterministic
508 // slots so its row ranges fold in a stable order across repeated threaded fits.
509 m_partialSums = NDArray<T, 1>({partialBlocks * k * d});
510 m_partialComps = NDArray<T, 1>({partialBlocks * k * d});
511 m_partialCounts = NDArray<std::int32_t, 1>({partialBlocks * k});
512
513 // Gemm A-pack arena sized to @c blocks * kMc * kKc so @c gemmRunPrepacked's per-worker
514 // slice indexing stays in-bounds on every fan-out path.
515 const std::size_t apSize = blocks * math::detail::kMc<T> * math::detail::kKc<T>;
516 m_gemmApArena = NDArray<T, 1>({needsChunk ? apSize : std::size_t{1}});
517
518 if (shapeChanged || workerChanged) {
519 resetPackedXApCacheMetadata();
520 if (packedXApCacheEnabledForShape(n, d, workerCount)) {
521 const std::size_t numChunks = (n + chunkCap - 1) / chunkCap;
522 m_packedXApChunkRows = chunkCap;
523 m_packedXApPerChunk = math::detail::packedAScratchSizeForRows<T>(chunkCap, d);
524 m_packedXAp = NDArray<T, 1>({numChunks * m_packedXApPerChunk});
525 } else {
526 m_packedXApChunkRows = 0;
527 m_packedXApPerChunk = 0;
528 m_packedXAp = NDArray<T, 1>({std::size_t{1}});
529 }
530 }
531
532 m_n = n;
533 m_d = d;
534 m_k = k;
535 m_workerCount = workerCount;
536 }
537
538 void refreshCentroidSqNorms(const NDArray<T, 2, Layout::Contig> &centroids) noexcept {
539 const std::size_t k = centroids.dim(0);
540 const std::size_t d = centroids.dim(1);
541 for (std::size_t c = 0; c < k; ++c) {
542 const T *row = centroids.data() + (c * d);
543 T s = T{0};
544 for (std::size_t t = 0; t < d; ++t) {
545 s += row[t] * row[t];
546 }
547 m_cSqNorms(c) = s;
548 }
549 }
550
551 void finalizeMeans(NDArray<T, 2, Layout::Contig> &centroids) noexcept {
552 const std::size_t k = centroids.dim(0);
553 const std::size_t d = centroids.dim(1);
554 for (std::size_t c = 0; c < k; ++c) {
555 const std::int32_t cnt = m_counts(c);
556 if (cnt <= 0) {
557 continue;
558 }
559 const T inv = T{1} / static_cast<T>(cnt);
560 const T *src = m_sums.data() + (c * d);
561 T *dst = centroids.data() + (c * d);
562 for (std::size_t t = 0; t < d; ++t) {
563 dst[t] = src[t] * inv;
564 }
565 }
566 }
567
575 void runAssignment(const NDArray<T, 2, Layout::Contig> &X,
576 const NDArray<T, 2, Layout::Contig> &centroids,
577 NDArray<std::int32_t, 1> &labels, math::Pool pool) {
578#ifdef CLUSTERING_USE_AVX2
579 if constexpr (std::is_same_v<T, float>) {
580 const std::size_t d = X.dim(1);
581 if (X.template isAligned<32>() && centroids.template isAligned<32>() && d != 0) {
582 if (d <= detail::kDirectArgminMaxD) {
583 math::detail::pairwiseArgminDirectSmallDF32(X, centroids, labels, m_minDistSq, pool);
584 return;
585 }
587 math::detail::pairwiseArgminOuterAvx2F32WithScratch(X, centroids, m_cSqNorms, labels,
588 m_minDistSq, m_packedB.data(),
589 m_packedCSqNorms.data(), pool);
590 return;
591 }
592 }
593 }
594#endif
595 runChunkedMaterializedAssignment(X, centroids, labels, pool);
596 }
597
603 [[nodiscard]] bool
604 assignmentProducesDirectMinDistSq(const NDArray<T, 2, Layout::Contig> &X,
605 const NDArray<T, 2, Layout::Contig> &C) noexcept {
606#ifdef CLUSTERING_USE_AVX2
607 if constexpr (std::is_same_v<T, float>) {
608 const std::size_t d = X.dim(1);
609 return X.template isAligned<32>() && C.template isAligned<32>() && d != 0 &&
611 } else {
612 (void)X;
613 (void)C;
614 return false;
615 }
616#else
617 (void)X;
618 (void)C;
619 return false;
620#endif
621 }
622
623 [[nodiscard]] bool assignmentUsesFusedArgmin(const NDArray<T, 2, Layout::Contig> &X,
624 const NDArray<T, 2, Layout::Contig> &C) noexcept {
625#ifdef CLUSTERING_USE_AVX2
626 if constexpr (std::is_same_v<T, float>) {
627 const std::size_t d = X.dim(1);
628 return X.template isAligned<32>() && C.template isAligned<32>() &&
630 } else {
631 (void)X;
632 (void)C;
633 return false;
634 }
635#else
636 (void)X;
637 (void)C;
638 return false;
639#endif
640 }
641
650 void packCentroidsTiled(const NDArray<T, 2, Layout::Contig> &centroids) noexcept {
651 constexpr std::size_t kNr = math::detail::kKernelNr<T>;
652 constexpr std::size_t kKcVal = math::detail::kKc<T>;
653 constexpr std::size_t kNcVal = math::detail::kNc<T>;
654 const std::size_t k = centroids.dim(0);
655 const std::size_t d = centroids.dim(1);
656 const auto cTransposed = centroids.t();
657 const auto cDesc = ::clustering::detail::describeMatrix(cTransposed);
658 T *bp = m_packedB.data();
659 std::size_t jcBase = 0;
660 for (std::size_t jc = 0; jc < k; jc += kNcVal) {
661 const std::size_t nc = (jc + kNcVal <= k) ? kNcVal : (k - jc);
662 const std::size_t roundedNc = ((nc + kNr - 1) / kNr) * kNr;
663 std::size_t pcOffInJc = 0;
664 for (std::size_t pc = 0; pc < d; pc += kKcVal) {
665 const std::size_t kc = (pc + kKcVal <= d) ? kKcVal : (d - pc);
666 math::detail::packB<T>(cDesc, pc, kc, jc, nc, bp + jcBase + pcOffInJc);
667 pcOffInJc += kc * roundedNc;
668 }
669 jcBase += d * roundedNc;
670 }
671 }
672
673 [[nodiscard]] bool ensurePackedXAp(const NDArray<T, 2, Layout::Contig> &X) noexcept {
674 if constexpr (std::is_same_v<T, float>) {
675 const std::size_t n = X.dim(0);
676 const std::size_t d = X.dim(1);
677 const std::size_t chunkCap = math::pairwiseArgminChunkRows;
678 if (!packedXApCacheEnabledForShape(n, d, m_workerCount) || m_packedXApPerChunk == 0 ||
679 m_packedXApChunkRows != chunkCap) {
680 return false;
681 }
682 if (m_packedXApCachedXData == X.data() && m_packedXApCachedN == n &&
683 m_packedXApCachedD == d) {
684 return true;
685 }
686
687 const T *xBase = X.data();
688 const std::size_t numChunks = (n + chunkCap - 1) / chunkCap;
689 for (std::size_t c = 0; c < numChunks; ++c) {
690 const std::size_t iBase = c * chunkCap;
691 const std::size_t chunkRows = (iBase + chunkCap <= n) ? chunkCap : (n - iBase);
692 auto xChunk = NDArray<T, 2, Layout::Contig>::borrow(const_cast<T *>(xBase) + (iBase * d),
693 {chunkRows, d});
694 const auto xDesc = ::clustering::detail::describeMatrix(xChunk);
695 math::detail::packAChunk<T>(xDesc, chunkRows, d, chunkCap,
696 m_packedXAp.data() + (c * m_packedXApPerChunk));
697 }
698
699 m_packedXApCachedXData = X.data();
700 m_packedXApCachedN = n;
701 m_packedXApCachedD = d;
702 return true;
703 } else {
704 (void)X;
705 return false;
706 }
707 }
708
709 void runChunkedMaterializedAssignment(const NDArray<T, 2, Layout::Contig> &X,
710 const NDArray<T, 2, Layout::Contig> &centroids,
711 NDArray<std::int32_t, 1> &labels,
712 math::Pool pool) noexcept {
713 const std::size_t n = X.dim(0);
714 const std::size_t k = centroids.dim(0);
715 const std::size_t d = X.dim(1);
716 if (n == 0 || k == 0) {
717 return;
718 }
719
720 packCentroidsTiled(centroids);
721
722 constexpr std::size_t kMcVal = math::detail::kMc<T>;
723 constexpr std::size_t kKcVal = math::detail::kKc<T>;
724 const std::size_t chunkCap = math::pairwiseArgminChunkRows;
725 const std::size_t numChunks = (n + chunkCap - 1) / chunkCap;
726 const T *bp = m_packedB.data();
727 T *apArena = m_gemmApArena.data();
728 T *distsBase = m_distsChunk.data();
729 const T *cNormsBase = m_cSqNorms.data();
730 T *minDistBase = m_minDistSq.data();
731 std::int32_t *labelsBase = labels.data();
732 const T *xBase = X.data();
733 // Hamerly bound seeding happens inline in the argmin post-pass; the cost is a handful of
734 // extra comparisons per row plus two @c sqrt calls, far below a second pass over @p X.
735 T *uBase = m_u.data();
736 T *lBase = m_l.data();
737 // Elkan bound seeding lights up only when the scratch matrix was sized for this shape;
738 // at shapes past @ref kElkanNKLimit or @ref kElkanMaxK the pointer stays null and the
739 // per-row loop skips the per-cluster bound stores.
740 T *elkanBoundsBase = m_elkanBounds.dim(0) == n ? m_elkanBounds.data() : nullptr;
741
742 auto runOneChunk = [&](std::size_t chunkIdx) noexcept {
743 const std::size_t iBase = chunkIdx * chunkCap;
744 const std::size_t chunkRows = (iBase + chunkCap <= n) ? chunkCap : (n - iBase);
745 const std::size_t w = math::Pool::workerIndex();
746 T *distsChunk = distsBase + (w * chunkCap * k);
747 T *apSlice = apArena + (w * kMcVal * kKcVal);
748
749 auto xChunk = NDArray<T, 2, Layout::Contig>::borrow(const_cast<T *>(xBase) + (iBase * d),
750 {chunkRows, d});
751 auto distsView = NDArray<T, 2>::borrow(distsChunk, {chunkRows, k});
752 const auto xDesc = ::clustering::detail::describeMatrix(xChunk);
753 auto distsDesc = ::clustering::detail::describeMatrixMut(distsView);
754 // Serial GEMM inside the chunk; outer fan-out already owns parallelism.
755 math::detail::gemmRunPrepacked<T>(xDesc, bp, d, k, distsDesc, T{-2}, T{0}, apSlice,
756 math::Pool{});
757
758 const T *xNormsChunk = m_xNormsSq.data() + iBase;
759 for (std::size_t i = 0; i < chunkRows; ++i) {
760 const T xn = xNormsChunk[i];
761 const T *row = distsChunk + (i * k);
762 T *elkanRow = elkanBoundsBase != nullptr ? elkanBoundsBase + ((iBase + i) * k) : nullptr;
763 T bestVal = std::numeric_limits<T>::infinity();
764 T secondVal = std::numeric_limits<T>::infinity();
765 std::int32_t bestIdx = 0;
766 for (std::size_t j = 0; j < k; ++j) {
767 T v = row[j] + xn + cNormsBase[j];
768 if (v < T{0}) {
769 v = T{0};
770 }
771 if (elkanRow != nullptr) {
772 elkanRow[j] = std::sqrt(v);
773 }
774 if (v < bestVal) {
775 secondVal = bestVal;
776 bestVal = v;
777 bestIdx = static_cast<std::int32_t>(j);
778 } else if (v < secondVal) {
779 secondVal = v;
780 }
781 }
782 minDistBase[iBase + i] = bestVal;
783 labelsBase[iBase + i] = bestIdx;
784 uBase[iBase + i] = std::sqrt(bestVal);
785 lBase[iBase + i] = std::sqrt(secondVal);
786 }
787 };
788
789 pool.parallelForBlocks(std::size_t{0}, numChunks, std::size_t{0},
790 [&](std::size_t lo, std::size_t hi) {
791 for (std::size_t c = lo; c < hi; ++c) {
792 runOneChunk(c);
793 }
794 });
795 }
796
808 void runAssignmentAndScatter(const NDArray<T, 2, Layout::Contig> &X,
809 const NDArray<T, 2, Layout::Contig> &centroids,
810 NDArray<std::int32_t, 1> &labels, std::size_t k, bool useKahan,
811 math::Pool pool) {
812 const std::size_t n = X.dim(0);
813 const std::size_t d = X.dim(1);
814 const std::size_t workers = pool.workerCount();
815 if (n == 0 || k == 0 || d == 0) {
816 return;
817 }
818
819 preZeroPartialSlabs(useKahan, workers, k, d);
820
821#ifdef CLUSTERING_USE_AVX2
822 const bool aligned32 = X.template isAligned<32>() && centroids.template isAligned<32>();
823#else
824 const bool aligned32 = false;
825#endif
826 const bool useDirect = aligned32 && d <= detail::kDirectArgminMaxD;
827 const bool useFused = aligned32 && !useDirect && d <= math::defaults::pairwiseArgminMaxD;
828 const bool useChunked = !useDirect && !useFused;
829
830#ifdef CLUSTERING_USE_AVX2
831 if constexpr (std::is_same_v<T, float>) {
832 if (useFused) {
833 math::detail::packCentroidsForFusedArgminF32(centroids, k, d, m_packedB.data());
834 math::detail::packCSqNorms<float>(m_cSqNorms.data(), k, m_packedCSqNorms.data());
835 }
836 }
837#endif
838 bool usePackedXAp = false;
839 if (useChunked) {
840 packCentroidsTiled(centroids);
841 usePackedXAp = ensurePackedXAp(X);
842 }
843
844#ifdef CLUSTERING_USE_AVX2
845 if constexpr (std::is_same_v<T, float>) {
846 if (useDirect) {
847 constexpr std::size_t kMr8 = 8;
848 constexpr std::size_t kMr16 = 16;
849 const bool useWideTile = workers > 1;
850 const std::size_t mTiles =
851 useWideTile ? ((n + kMr16 - 1) / kMr16) : ((n + kMr8 - 1) / kMr8);
852 pool.parallelForExactBlocksWithSlot<citor::HintsDefaults>(
853 std::size_t{0}, mTiles, workers,
854 [&](std::size_t lo, std::size_t hi, std::size_t slot) noexcept {
855 if (useWideTile) {
856 assignScatterDirect16Tiles(X, centroids, labels, k, useKahan, lo, hi, slot);
857 } else {
858 assignScatterDirectTiles(X, centroids, labels, k, useKahan, lo, hi, slot);
859 }
860 });
861 foldPartialSlabs(useKahan, workers, k, d);
862 return;
863 }
864 if (useFused) {
865 constexpr std::size_t kMr8 = math::detail::kKernelMr<float>;
866 constexpr std::size_t kMr16 = 16;
867 const bool useWideTile = workers > 1;
868 const std::size_t mTiles =
869 useWideTile ? ((n + kMr16 - 1) / kMr16) : ((n + kMr8 - 1) / kMr8);
870 pool.parallelForExactBlocksWithSlot<citor::HintsDefaults>(
871 std::size_t{0}, mTiles, workers,
872 [&](std::size_t lo, std::size_t hi, std::size_t slot) noexcept {
873 if (useWideTile) {
874 assignScatterFused16Tiles(X, labels, k, useKahan, lo, hi, slot);
875 } else {
876 assignScatterFusedTiles(X, labels, k, useKahan, lo, hi, slot);
877 }
878 });
879 foldPartialSlabs(useKahan, workers, k, d);
880 return;
881 }
882 }
883#endif
884
885 // Chunked path (d > pairwiseArgminMaxD or T == double): fan out over chunks.
886 const std::size_t chunkCap = math::pairwiseArgminChunkRows;
887 const std::size_t numChunks = (n + chunkCap - 1) / chunkCap;
888 pool.parallelForExactBlocksWithSlot<citor::HintsDefaults>(
889 std::size_t{0}, numChunks, workers,
890 [&](std::size_t chunkLo, std::size_t chunkHi, std::size_t slot) noexcept {
891 assignScatterChunkRange(X, labels, k, useKahan, chunkLo, chunkHi, slot, usePackedXAp);
892 });
893
894 foldPartialSlabs(useKahan, workers, k, d);
895 }
896
901 void assignScatterChunkRange(const NDArray<T, 2, Layout::Contig> &X,
902 NDArray<std::int32_t, 1> &labels, std::size_t k, bool useKahan,
903 std::size_t chunkLo, std::size_t chunkHi, std::size_t slot,
904 bool usePackedXAp) noexcept {
905 constexpr std::size_t kMcVal = math::detail::kMc<T>;
906 constexpr std::size_t kKcVal = math::detail::kKc<T>;
907 const std::size_t n = X.dim(0);
908 const std::size_t d = X.dim(1);
909 const std::size_t chunkCap = math::pairwiseArgminChunkRows;
910 const T *xBase = X.data();
911 std::int32_t *labelsBase = labels.data();
912 const T *bp = m_packedB.data();
913 const T *cNormsBase = m_cSqNorms.data();
914 T *minDistBase = m_minDistSq.data();
915 T *uBase = m_u.data();
916 T *lBase = m_l.data();
917 T *elkanBoundsBase = m_elkanBounds.dim(0) == n ? m_elkanBounds.data() : nullptr;
918 T *distsChunk = m_distsChunk.data() + (slot * chunkCap * k);
919 T *apSlice = m_gemmApArena.data() + (slot * kMcVal * kKcVal);
920 const T *packedXBase = usePackedXAp ? m_packedXAp.data() : nullptr;
921 const std::size_t packedXStride = m_packedXApPerChunk;
922
923 for (std::size_t c = chunkLo; c < chunkHi; ++c) {
924 const std::size_t iBase = c * chunkCap;
925 const std::size_t chunkRows = (iBase + chunkCap <= n) ? chunkCap : (n - iBase);
926
927 auto distsView = NDArray<T, 2>::borrow(distsChunk, {chunkRows, k});
928 auto distsDesc = ::clustering::detail::describeMatrixMut(distsView);
929 // Serial GEMM inside the chunk; the outer fan-out already owns parallelism.
930 if (packedXBase != nullptr) {
931 math::detail::gemmRunPrepackedAB<T>(packedXBase + (c * packedXStride), chunkRows, chunkCap,
932 bp, d, k, distsDesc, T{-2}, T{0});
933 } else {
934 auto xChunk = NDArray<T, 2, Layout::Contig>::borrow(const_cast<T *>(xBase) + (iBase * d),
935 {chunkRows, d});
936 const auto xDesc = ::clustering::detail::describeMatrix(xChunk);
937 math::detail::gemmRunPrepacked<T>(xDesc, bp, d, k, distsDesc, T{-2}, T{0}, apSlice,
938 math::Pool{});
939 }
940
941 const T *xNormsChunk = m_xNormsSq.data() + iBase;
942 for (std::size_t i = 0; i < chunkRows; ++i) {
943 const T xn = xNormsChunk[i];
944 const T *row = distsChunk + (i * k);
945 T *elkanRow = elkanBoundsBase != nullptr ? elkanBoundsBase + ((iBase + i) * k) : nullptr;
946 T bestVal = std::numeric_limits<T>::infinity();
947 T secondVal = std::numeric_limits<T>::infinity();
948 std::int32_t bestIdx = 0;
949 for (std::size_t j = 0; j < k; ++j) {
950 T v = row[j] + xn + cNormsBase[j];
951 if (v < T{0}) {
952 v = T{0};
953 }
954 if (elkanRow != nullptr) {
955 elkanRow[j] = std::sqrt(v);
956 }
957 if (v < bestVal) {
958 secondVal = bestVal;
959 bestVal = v;
960 bestIdx = static_cast<std::int32_t>(j);
961 } else if (v < secondVal) {
962 secondVal = v;
963 }
964 }
965 minDistBase[iBase + i] = bestVal;
966 labelsBase[iBase + i] = bestIdx;
967 uBase[iBase + i] = std::sqrt(bestVal);
968 lBase[iBase + i] = std::sqrt(secondVal);
969 scatterRowToSlab(xBase, labelsBase, iBase + i, slot, k, d, useKahan);
970 }
971 }
972 }
973
974#ifdef CLUSTERING_USE_AVX2
976 void assignScatterDirectTiles(const NDArray<T, 2, Layout::Contig> &X,
977 const NDArray<T, 2, Layout::Contig> &centroids,
978 NDArray<std::int32_t, 1> &labels, std::size_t k, bool useKahan,
979 std::size_t tileLo, std::size_t tileHi, std::size_t slot) noexcept {
980 constexpr std::size_t kMr = 8;
981 const std::size_t n = X.dim(0);
982 const std::size_t d = X.dim(1);
983 const T *xBase = X.data();
984 const std::int32_t *labelsBase = labels.data();
985 for (std::size_t t = tileLo; t < tileHi; ++t) {
986 math::detail::argminDirectMTileF32(X, centroids, labels, m_minDistSq, t, n, k, d);
987 const std::size_t iBase = t * kMr;
988 const std::size_t mc = (iBase + kMr <= n) ? kMr : (n - iBase);
989 for (std::size_t r = 0; r < mc; ++r) {
990 scatterRowToSlab(xBase, labelsBase, iBase + r, slot, k, d, useKahan);
991 }
992 }
993 }
994
996 void assignScatterDirect16Tiles(const NDArray<T, 2, Layout::Contig> &X,
997 const NDArray<T, 2, Layout::Contig> &centroids,
998 NDArray<std::int32_t, 1> &labels, std::size_t k, bool useKahan,
999 std::size_t tileLo, std::size_t tileHi,
1000 std::size_t slot) noexcept {
1001 constexpr std::size_t kMr = 16;
1002 const std::size_t n = X.dim(0);
1003 const std::size_t d = X.dim(1);
1004 const T *xBase = X.data();
1005 const std::int32_t *labelsBase = labels.data();
1006 for (std::size_t t = tileLo; t < tileHi; ++t) {
1007 math::detail::argminDirectM16TileF32(X, centroids, labels, m_minDistSq, t, n, k, d);
1008 const std::size_t iBase = t * kMr;
1009 const std::size_t mc = (iBase + kMr <= n) ? kMr : (n - iBase);
1010 for (std::size_t r = 0; r < mc; ++r) {
1011 scatterRowToSlab(xBase, labelsBase, iBase + r, slot, k, d, useKahan);
1012 }
1013 }
1014 }
1015
1018 void assignScatterFusedTiles(const NDArray<T, 2, Layout::Contig> &X,
1019 NDArray<std::int32_t, 1> &labels, std::size_t k, bool useKahan,
1020 std::size_t tileLo, std::size_t tileHi, std::size_t slot) noexcept {
1021 constexpr std::size_t kMr = math::detail::kKernelMr<float>;
1022 const std::size_t n = X.dim(0);
1023 const std::size_t d = X.dim(1);
1024 const T *xBase = X.data();
1025 const std::int32_t *labelsBase = labels.data();
1026 const float *bpacked = m_packedB.data();
1027 const float *normsPacked = m_packedCSqNorms.data();
1028 for (std::size_t t = tileLo; t < tileHi; ++t) {
1029 math::detail::argminFusedMTileF32(X, bpacked, normsPacked, labels, m_minDistSq, t, n, k, d);
1030 const std::size_t iBase = t * kMr;
1031 const std::size_t mc = (iBase + kMr <= n) ? kMr : (n - iBase);
1032 for (std::size_t r = 0; r < mc; ++r) {
1033 scatterRowToSlab(xBase, labelsBase, iBase + r, slot, k, d, useKahan);
1034 }
1035 }
1036 }
1037
1039 void assignScatterFused16Tiles(const NDArray<T, 2, Layout::Contig> &X,
1040 NDArray<std::int32_t, 1> &labels, std::size_t k, bool useKahan,
1041 std::size_t tileLo, std::size_t tileHi,
1042 std::size_t slot) noexcept {
1043 constexpr std::size_t kMr = 16;
1044 const std::size_t n = X.dim(0);
1045 const std::size_t d = X.dim(1);
1046 const T *xBase = X.data();
1047 const std::int32_t *labelsBase = labels.data();
1048 const float *bpacked = m_packedB.data();
1049 const float *normsPacked = m_packedCSqNorms.data();
1050 for (std::size_t t = tileLo; t < tileHi; ++t) {
1051 math::detail::argminFusedM16TileF32(X, bpacked, normsPacked, labels, m_minDistSq, t, n, k, d);
1052 const std::size_t iBase = t * kMr;
1053 const std::size_t mc = (iBase + kMr <= n) ? kMr : (n - iBase);
1054 for (std::size_t r = 0; r < mc; ++r) {
1055 scatterRowToSlab(xBase, labelsBase, iBase + r, slot, k, d, useKahan);
1056 }
1057 }
1058 }
1059
1076 void runPlexLoop(const NDArray<T, 2, Layout::Contig> &X, NDArray<T, 2, Layout::Contig> &centroids,
1077 NDArray<std::int32_t, 1> &labels, std::size_t k, std::size_t maxIter,
1078 T shiftSqThreshold, bool useKahan, bool hamerlyEligible, math::Pool pool,
1079 std::size_t &iter, bool &converged) {
1080 const std::size_t n = X.dim(0);
1081 const std::size_t d = X.dim(1);
1082 const std::size_t workers = pool.workerCount();
1083 const bool useDirect = d <= detail::kDirectArgminMaxD;
1084 const bool useChunked = d > math::defaults::pairwiseArgminMaxD;
1085 // Partition work units, not rows, so a kernel tile or GEMM chunk never straddles two
1086 // slots.
1087 const std::size_t unit = useChunked ? math::pairwiseArgminChunkRows : std::size_t{16};
1088 const std::size_t units = (n + unit - 1) / unit;
1089
1090 HamerlyShiftTop2 top2{};
1091 bool usePackedXAp = false;
1092 bool stopPhases = false;
1093 auto plexTok = citor::CancellationToken::makeOwned();
1094
1095 auto packFusedCentroids = [&]() noexcept {
1096 math::detail::packCentroidsForFusedArgminF32(centroids, k, d, m_packedB.data());
1097 math::detail::packCSqNorms<float>(m_cSqNorms.data(), k, m_packedCSqNorms.data());
1098 };
1099
1100 // Fold + serial glue for the iteration whose scatter just finished; identical to the
1101 // fallback loop's tail. Callers inside the plex pass the serial pool because the
1102 // workers are plex-resident and cannot pick up a nested dispatch.
1103 auto iterationGlue = [&](math::Pool gluePool) {
1104 foldPartialSlabs(useKahan, workers, k, d);
1105 (void)::clustering::kmeans::detail::reseedEmptyClusters<T>(X, centroids, m_sums, m_counts,
1106 m_minDistSq);
1107 finalizeMeans(centroids);
1108 refreshCentroidSqNorms(centroids);
1109 math::centroidShift<T>(m_centroidsOld, centroids, m_shiftSq, gluePool);
1110 const T totalShift = ::clustering::kmeans::detail::totalShiftSqKahan<T>(m_shiftSq);
1111 ++iter;
1112 if (totalShift <= shiftSqThreshold) {
1113 converged = true;
1114 }
1115 };
1116
1117 auto prePhase = [&](std::size_t phaseIdx) {
1118 if (phaseIdx == 0) {
1119 std::memcpy(m_centroidsOld.data(), centroids.data(), k * d * sizeof(T));
1120 preZeroPartialSlabs(useKahan, workers, k, d);
1121 if (useChunked) {
1122 packCentroidsTiled(centroids);
1123 usePackedXAp = ensurePackedXAp(X);
1124 } else if (!useDirect) {
1125 packFusedCentroids();
1126 }
1127 return;
1128 }
1129 iterationGlue(math::Pool{});
1130 if (converged) {
1131 stopPhases = true;
1132 plexTok.request_stop();
1133 return;
1134 }
1135 std::memcpy(m_centroidsOld.data(), centroids.data(), k * d * sizeof(T));
1136 preZeroPartialSlabs(useKahan, workers, k, d);
1137 if (hamerlyEligible) {
1138 top2 = prepareHamerlyGeometry(centroids, k, d);
1139 } else if (useChunked) {
1140 packCentroidsTiled(centroids);
1141 usePackedXAp = ensurePackedXAp(X);
1142 } else if (!useDirect) {
1143 packFusedCentroids();
1144 }
1145 };
1146
1147 auto phase = [&](std::size_t phaseIdx, std::uint32_t slot, std::size_t lo, std::size_t hi,
1148 void * /*tlsArena*/ = nullptr) noexcept {
1149 if (stopPhases) {
1150 return;
1151 }
1152 const auto s = static_cast<std::size_t>(slot);
1153 if (hamerlyEligible && phaseIdx > 0) {
1154 hamerlyAssignScatterRange(X, centroids, labels, k, useKahan, top2, std::min(lo * unit, n),
1155 std::min(hi * unit, n), s);
1156 return;
1157 }
1158 if (useChunked) {
1159 // The chunk body seeds the Hamerly bounds inline in its argmin post-pass.
1160 assignScatterChunkRange(X, labels, k, useKahan, lo, hi, s, usePackedXAp);
1161 return;
1162 }
1163 if (useDirect) {
1164 assignScatterDirect16Tiles(X, centroids, labels, k, useKahan, lo, hi, s);
1165 } else {
1166 assignScatterFused16Tiles(X, labels, k, useKahan, lo, hi, s);
1167 }
1168 if (hamerlyEligible && phaseIdx == 0) {
1169 seedHamerlyBoundsFromMinDistRange(labels, k, d, std::min(lo * unit, n),
1170 std::min(hi * unit, n));
1171 }
1172 };
1173
1174 pool.parallelRunPlex<citor::HintsDefaults>(maxIter, units, std::move(phase),
1175 std::move(prePhase), plexTok);
1176
1177 if (!converged) {
1178 // No pre-phase hook follows the last phase; its glue runs here, with the caller's
1179 // pool restored now that the plex has drained.
1180 iterationGlue(pool);
1181 }
1182 }
1183#endif
1184
1185 void preZeroPartialSlabs(bool useKahan, std::size_t numBlocks, std::size_t k,
1186 std::size_t d) noexcept {
1187 T *partialSums = m_partialSums.data();
1188 std::int32_t *partialCounts = m_partialCounts.data();
1189
1190 for (std::size_t c = 0; c < k; ++c) {
1191 m_counts(c) = 0;
1192 for (std::size_t t = 0; t < d; ++t) {
1193 m_sums(c, t) = T{0};
1194 }
1195 }
1196 if (useKahan) {
1197 T *foldComp = m_foldComp.data();
1198 for (std::size_t e = 0; e < k * d; ++e) {
1199 foldComp[e] = T{0};
1200 }
1201 T *partialComps = m_partialComps.data();
1202 for (std::size_t b = 0; b < numBlocks; ++b) {
1203 T *slab = partialSums + (b * k * d);
1204 T *cslab = partialComps + (b * k * d);
1205 std::int32_t *nslab = partialCounts + (b * k);
1206 for (std::size_t e = 0; e < k * d; ++e) {
1207 slab[e] = T{0};
1208 cslab[e] = T{0};
1209 }
1210 for (std::size_t c = 0; c < k; ++c) {
1211 nslab[c] = 0;
1212 }
1213 }
1214 } else {
1215 for (std::size_t b = 0; b < numBlocks; ++b) {
1216 T *slab = partialSums + (b * k * d);
1217 std::int32_t *cslab = partialCounts + (b * k);
1218 for (std::size_t e = 0; e < k * d; ++e) {
1219 slab[e] = T{0};
1220 }
1221 for (std::size_t c = 0; c < k; ++c) {
1222 cslab[c] = 0;
1223 }
1224 }
1225 }
1226 }
1227
1228 void foldPartialSlabs(bool useKahan, std::size_t numBlocks, std::size_t k,
1229 std::size_t d) noexcept {
1230 const T *partialSums = m_partialSums.data();
1231 const std::int32_t *partialCounts = m_partialCounts.data();
1232 if (useKahan) {
1233 const T *partialComps = m_partialComps.data();
1234 T *foldComp = m_foldComp.data();
1235 for (std::size_t b = 0; b < numBlocks; ++b) {
1236 const T *slab = partialSums + (b * k * d);
1237 const T *cslab = partialComps + (b * k * d);
1238 const std::int32_t *nslab = partialCounts + (b * k);
1239 for (std::size_t c = 0; c < k; ++c) {
1240 m_counts(c) += nslab[c];
1241 const T *src = slab + (c * d);
1242 const T *comp = cslab + (c * d);
1243 T *dstRow = &m_sums(c, 0);
1244 T *foldRow = foldComp + (c * d);
1245 for (std::size_t t = 0; t < d; ++t) {
1246 const T addend = src[t] - comp[t];
1247 const T y = addend - foldRow[t];
1248 const T tVal = dstRow[t] + y;
1249 foldRow[t] = (tVal - dstRow[t]) - y;
1250 dstRow[t] = tVal;
1251 }
1252 }
1253 }
1254 } else {
1255 for (std::size_t b = 0; b < numBlocks; ++b) {
1256 const T *slab = partialSums + (b * k * d);
1257 const std::int32_t *cslab = partialCounts + (b * k);
1258 for (std::size_t c = 0; c < k; ++c) {
1259 m_counts(c) += cslab[c];
1260 const T *src = slab + (c * d);
1261 T *dstRow = &m_sums(c, 0);
1262 for (std::size_t t = 0; t < d; ++t) {
1263 dstRow[t] += src[t];
1264 }
1265 }
1266 }
1267 }
1268 }
1269
1270 [[nodiscard]] static std::size_t hamerlyScatterBlocks(std::size_t n,
1271 std::size_t workers) noexcept {
1272 if (workers <= 1 || n == 0) {
1273 return std::max<std::size_t>(workers, std::size_t{1});
1274 }
1275 constexpr std::size_t kMinRowsPerBlock = 256;
1276 const std::size_t byRows = std::max<std::size_t>(1, n / kMinRowsPerBlock);
1277 const std::size_t blocks = std::min(workers * 8, byRows);
1278 return std::max(blocks, workers);
1279 }
1280
1281 void recomputeMinDistSqDirect(const NDArray<T, 2, Layout::Contig> &X,
1282 const NDArray<T, 2, Layout::Contig> &centroids,
1283 const NDArray<std::int32_t, 1> &labels, math::Pool pool) noexcept {
1284 const std::size_t n = X.dim(0);
1285 const std::size_t d = X.dim(1);
1286 const std::size_t k = centroids.dim(0);
1287 if (n == 0 || d == 0 || k == 0) {
1288 return;
1289 }
1290
1291 auto runRowRange = [&](std::size_t lo, std::size_t hi) noexcept {
1292 for (std::size_t i = lo; i < hi; ++i) {
1293 const std::int32_t lbl = labels(i);
1294 if (lbl < 0 || std::cmp_greater_equal(lbl, k)) {
1295 m_minDistSq(i) = T{0};
1296 continue;
1297 }
1298 const T *xRow = X.data() + (i * d);
1299 const T *cRow = centroids.data() + (static_cast<std::size_t>(lbl) * d);
1300 m_minDistSq(i) = math::detail::sqEuclideanRowPtr<T>(xRow, cRow, d);
1301 }
1302 };
1303
1304 pool.parallelForBlocks(std::size_t{0}, n, std::size_t{0},
1305 [&](std::size_t lo, std::size_t hi) { runRowRange(lo, hi); });
1306 }
1307
1309 void seedHamerlyBoundsFromMinDistRange(const NDArray<std::int32_t, 1> &labels, std::size_t k,
1310 std::size_t d, std::size_t lo, std::size_t hi) noexcept {
1311 // Fused decomposed distances can round below the direct squared-distance sum; inflate the
1312 // Euclidean upper bound so Hamerly's prune stays conservative.
1313 const T slackScale = static_cast<T>(8) * std::numeric_limits<T>::epsilon() * static_cast<T>(d);
1314 for (std::size_t i = lo; i < hi; ++i) {
1315 const std::int32_t lbl = labels(i);
1316 if (lbl < 0 || std::cmp_greater_equal(lbl, k)) {
1317 m_minDistSq(i) = T{0};
1318 m_u(i) = std::numeric_limits<T>::infinity();
1319 m_l(i) = T{0};
1320 continue;
1321 }
1322 T tightSq = m_minDistSq(i);
1323 if (tightSq < T{0}) {
1324 tightSq = T{0};
1325 m_minDistSq(i) = T{0};
1326 }
1327 m_minDistSq(i) = tightSq;
1328 const T u = std::sqrt(tightSq);
1329 m_u(i) = u + ((u + T{1}) * slackScale);
1330 m_l(i) = T{0};
1331 }
1332 }
1333
1334 void seedHamerlyBoundsFromAssignedMinDist(const NDArray<std::int32_t, 1> &labels, std::size_t k,
1335 std::size_t d, math::Pool pool) noexcept {
1336 const std::size_t n = labels.dim(0);
1337 if (n == 0 || d == 0 || k == 0) {
1338 return;
1339 }
1340
1341 pool.parallelForBlocks(std::size_t{0}, n, std::size_t{0}, [&](std::size_t lo, std::size_t hi) {
1342 seedHamerlyBoundsFromMinDistRange(labels, k, d, lo, hi);
1343 });
1344 }
1345
1352 static constexpr std::size_t kHamerlyMaxK = 64;
1353
1362 static constexpr std::size_t kHamerlyMinDirectScanDims = 128;
1363
1372 static constexpr std::size_t kHamerlyDirectWorkerCap = 4;
1373
1380 static constexpr std::size_t kElkanMaxK = 4096;
1381
1389 static constexpr std::size_t kElkanNKLimit = std::size_t{32} << 20;
1390
1401 [[nodiscard]] HamerlyShiftTop2
1402 prepareHamerlyGeometry(const NDArray<T, 2, Layout::Contig> &centroids, std::size_t k,
1403 std::size_t d) noexcept {
1404 HamerlyShiftTop2 top2{};
1405 const T *cData = centroids.data();
1406 T *shiftData = m_shiftEuclidean.data();
1407 for (std::size_t c = 0; c < k; ++c) {
1408 const T s = std::sqrt(m_shiftSq(c));
1409 shiftData[c] = s;
1410 if (s > top2.sMax) {
1411 top2.s2Max = top2.sMax;
1412 top2.sMax = s;
1413 top2.argMax = c;
1414 } else if (s > top2.s2Max) {
1415 top2.s2Max = s;
1416 }
1417 }
1418
1419 T *halfDistData = m_halfDistToNearestOther.data();
1420 for (std::size_t c = 0; c < k; ++c) {
1421 T nearestSq = std::numeric_limits<T>::infinity();
1422 const T *caRow = cData + (c * d);
1423 for (std::size_t cp = 0; cp < k; ++cp) {
1424 if (cp == c) {
1425 continue;
1426 }
1427 const T dsq = math::detail::sqEuclideanRowPtr<T>(caRow, cData + (cp * d), d);
1428 if (dsq < nearestSq) {
1429 nearestSq = dsq;
1430 }
1431 }
1432 halfDistData[c] = T{0.5} * std::sqrt(nearestSq);
1433 }
1434 return top2;
1435 }
1436
1440 void hamerlyAssignScatterRange(const NDArray<T, 2, Layout::Contig> &X,
1441 const NDArray<T, 2, Layout::Contig> &centroids,
1442 NDArray<std::int32_t, 1> &labels, std::size_t k, bool useKahan,
1443 const HamerlyShiftTop2 &top2, std::size_t lo, std::size_t hi,
1444 std::size_t slot) noexcept {
1445 const std::size_t d = X.dim(1);
1446 const T *xData = X.data();
1447 const T *cData = centroids.data();
1448 T *uData = m_u.data();
1449 T *lData = m_l.data();
1450 T *minDistData = m_minDistSq.data();
1451 std::int32_t *labelsData = labels.data();
1452 const T *shiftData = m_shiftEuclidean.data();
1453 const T *halfDistData = m_halfDistToNearestOther.data();
1454 T *slabSum = m_partialSums.data() + (slot * k * d);
1455 T *slabComp = m_partialComps.data() + (slot * k * d);
1456 std::int32_t *slabCnt = m_partialCounts.data() + (slot * k);
1457
1458 std::array<T, kHamerlyMaxK> distBuf{};
1459 for (std::size_t i = lo; i < hi; ++i) {
1460 const std::int32_t a = labelsData[i];
1461 if (a < 0 || std::cmp_greater_equal(a, k)) {
1462 continue;
1463 }
1464 const auto au = static_cast<std::size_t>(a);
1465 T ui = uData[i] + shiftData[au];
1466 T li = lData[i] - ((au == top2.argMax) ? top2.s2Max : top2.sMax);
1467 std::int32_t bestLabel = a;
1468 bool labelDecided = false;
1469
1470 if (ui <= li || ui <= halfDistData[au]) {
1471 uData[i] = ui;
1472 lData[i] = li;
1473 labelDecided = true;
1474 }
1475
1476 if (!labelDecided) {
1477 const T *xi = xData + (i * d);
1478 const T *caRow = cData + (au * d);
1479 const T tightSq = math::detail::sqEuclideanRowPtr<T>(xi, caRow, d);
1480 ui = std::sqrt(tightSq);
1481
1482 if (ui <= li) {
1483 uData[i] = ui;
1484 lData[i] = li;
1485 minDistData[i] = tightSq;
1486 labelDecided = true;
1487 } else {
1488 detail::sqEuclideanRowToBatch<T>(xi, cData, k, d, distBuf.data());
1489 T best = std::numeric_limits<T>::infinity();
1490 T second = std::numeric_limits<T>::infinity();
1491 std::int32_t bestIdx = 0;
1492 for (std::size_t j = 0; j < k; ++j) {
1493 const T v = distBuf[j];
1494 if (v < best) {
1495 second = best;
1496 best = v;
1497 bestIdx = static_cast<std::int32_t>(j);
1498 } else if (v < second) {
1499 second = v;
1500 }
1501 }
1502 bestLabel = bestIdx;
1503 labelsData[i] = bestIdx;
1504 minDistData[i] = best;
1505 uData[i] = std::sqrt(best);
1506 lData[i] = std::sqrt(second);
1507 }
1508 }
1509
1510 if (bestLabel < 0 || std::cmp_greater_equal(bestLabel, k)) {
1511 continue;
1512 }
1513 const auto row = static_cast<std::size_t>(bestLabel);
1514 const T *xRow = xData + (i * d);
1515 T *sumRow = slabSum + (row * d);
1516 if (useKahan) {
1517 T *compRow = slabComp + (row * d);
1518 math::detail::kahanAddRow<T>(xRow, d, sumRow, compRow);
1519 } else {
1520 for (std::size_t t = 0; t < d; ++t) {
1521 sumRow[t] += xRow[t];
1522 }
1523 }
1524 slabCnt[row] += 1;
1525 }
1526 }
1527
1537 void runHamerlyAssignmentAndScatter(const NDArray<T, 2, Layout::Contig> &X,
1538 const NDArray<T, 2, Layout::Contig> &centroids,
1539 NDArray<std::int32_t, 1> &labels, std::size_t k,
1540 bool useKahan, math::Pool pool) noexcept {
1541 const std::size_t n = X.dim(0);
1542 const std::size_t d = X.dim(1);
1543 if (n == 0 || d == 0 || k == 0 || k > kHamerlyMaxK) {
1544 return;
1545 }
1546 const std::size_t workers = pool.workerCount();
1547 const std::size_t blocks = hamerlyScatterBlocks(n, workers);
1548 preZeroPartialSlabs(useKahan, blocks, k, d);
1549
1550 const HamerlyShiftTop2 top2 = prepareHamerlyGeometry(centroids, k, d);
1551
1552 pool.parallelForExactBlocksWithSlot<citor::HintsDefaults>(
1553 std::size_t{0}, n, blocks, [&](std::size_t lo, std::size_t hi, std::size_t slot) noexcept {
1554 hamerlyAssignScatterRange(X, centroids, labels, k, useKahan, top2, lo, hi, slot);
1555 });
1556
1557 foldPartialSlabs(useKahan, blocks, k, d);
1558 }
1559
1566 void runElkanAssignmentAndScatter(const NDArray<T, 2, Layout::Contig> &X,
1567 const NDArray<T, 2, Layout::Contig> &centroids,
1568 NDArray<std::int32_t, 1> &labels, std::size_t k, bool useKahan,
1569 math::Pool pool) noexcept {
1570 const std::size_t n = X.dim(0);
1571 const std::size_t d = X.dim(1);
1572 if (n == 0 || d == 0 || k == 0 || m_elkanBounds.dim(0) != n || m_elkanBounds.dim(1) != k) {
1573 return;
1574 }
1575 const std::size_t workers = pool.workerCount();
1576 preZeroPartialSlabs(useKahan, workers, k, d);
1577
1578 const T *xData = X.data();
1579 const T *cData = centroids.data();
1580 T *uData = m_u.data();
1581 T *boundsData = m_elkanBounds.data();
1582 T *minDistData = m_minDistSq.data();
1583 std::int32_t *labelsData = labels.data();
1584
1585 T *shiftData = m_shiftEuclidean.data();
1586 for (std::size_t c = 0; c < k; ++c) {
1587 shiftData[c] = std::sqrt(m_shiftSq(c));
1588 }
1589
1590 T *centerDistData = m_centerDist.data();
1591 T *halfDistData = m_halfDistToNearestOther.data();
1592 for (std::size_t c = 0; c < k; ++c) {
1593 centerDistData[(c * k) + c] = T{0};
1594 T nearest = std::numeric_limits<T>::infinity();
1595 for (std::size_t cp = 0; cp < k; ++cp) {
1596 if (cp == c) {
1597 continue;
1598 }
1599 T dist;
1600 if (cp > c) {
1601 const T dsq = math::detail::sqEuclideanRowPtr<T>(cData + (c * d), cData + (cp * d), d);
1602 dist = std::sqrt(dsq);
1603 centerDistData[(c * k) + cp] = dist;
1604 centerDistData[(cp * k) + c] = dist;
1605 } else {
1606 dist = centerDistData[(c * k) + cp];
1607 }
1608 if (dist < nearest) {
1609 nearest = dist;
1610 }
1611 }
1612 halfDistData[c] = T{0.5} * nearest;
1613 }
1614
1615 T *partialSums = m_partialSums.data();
1616 T *partialComps = m_partialComps.data();
1617 std::int32_t *partialCounts = m_partialCounts.data();
1618
1619 pool.parallelForBlocks<citor::HintsDefaults>(
1620 std::size_t{0}, n, std::size_t{0}, [&](std::size_t lo, std::size_t hi) noexcept {
1621 const std::size_t slot = math::Pool::workerIndex();
1622 T *slabSum = partialSums + (slot * k * d);
1623 T *slabComp = partialComps + (slot * k * d);
1624 std::int32_t *slabCnt = partialCounts + (slot * k);
1625 for (std::size_t i = lo; i < hi; ++i) {
1626 std::int32_t a = labelsData[i];
1627 if (a < 0 || std::cmp_greater_equal(a, k)) {
1628 continue;
1629 }
1630 auto au = static_cast<std::size_t>(a);
1631 T u = uData[i] + shiftData[au];
1632 T *lRow = boundsData + (i * k);
1633 for (std::size_t c = 0; c < k; ++c) {
1634 T lnew = lRow[c] - shiftData[c];
1635 if (lnew < T{0}) {
1636 lnew = T{0};
1637 }
1638 lRow[c] = lnew;
1639 }
1640
1641 if (u <= halfDistData[au]) {
1642 uData[i] = u;
1643 } else {
1644 bool uTight = false;
1645 const T *xi = xData + (i * d);
1646 for (std::size_t c = 0; c < k; ++c) {
1647 if (c == au) {
1648 continue;
1649 }
1650 const T lc = lRow[c];
1651 const T half = T{0.5} * centerDistData[(au * k) + c];
1652 if (u <= lc || u <= half) {
1653 continue;
1654 }
1655 if (!uTight) {
1656 const T tightSq = math::detail::sqEuclideanRowPtr<T>(xi, cData + (au * d), d);
1657 u = std::sqrt(tightSq);
1658 minDistData[i] = tightSq;
1659 uTight = true;
1660 if (u <= lc || u <= half) {
1661 continue;
1662 }
1663 }
1664 const T dSq = math::detail::sqEuclideanRowPtr<T>(xi, cData + (c * d), d);
1665 const T dEuc = std::sqrt(dSq);
1666 lRow[c] = dEuc;
1667 if (dEuc < u) {
1668 au = c;
1669 a = static_cast<std::int32_t>(c);
1670 u = dEuc;
1671 minDistData[i] = dSq;
1672 }
1673 }
1674 uData[i] = u;
1675 labelsData[i] = a;
1676 }
1677
1678 const auto row = static_cast<std::size_t>(a);
1679 const T *xRow = xData + (i * d);
1680 T *sumRow = slabSum + (row * d);
1681 if (useKahan) {
1682 T *compRow = slabComp + (row * d);
1683 math::detail::kahanAddRow<T>(xRow, d, sumRow, compRow);
1684 } else {
1685 for (std::size_t t = 0; t < d; ++t) {
1686 sumRow[t] += xRow[t];
1687 }
1688 }
1689 slabCnt[row] += 1;
1690 }
1691 });
1692
1693 foldPartialSlabs(useKahan, workers, k, d);
1694 }
1695
1706 void runHamerlyAssignment(const NDArray<T, 2, Layout::Contig> &X,
1707 const NDArray<T, 2, Layout::Contig> &centroids,
1708 NDArray<std::int32_t, 1> &labels, math::Pool pool,
1709 bool refreshAssignedMinDist = false) noexcept {
1710 const std::size_t n = X.dim(0);
1711 const std::size_t d = X.dim(1);
1712 const std::size_t k = centroids.dim(0);
1713 if (n == 0 || d == 0 || k == 0 || k > kHamerlyMaxK) {
1714 return;
1715 }
1716 const T *xData = X.data();
1717 const T *cData = centroids.data();
1718 T *uData = m_u.data();
1719 T *lData = m_l.data();
1720 T *minDistData = m_minDistSq.data();
1721 std::int32_t *labelsData = labels.data();
1722
1723 // The second-largest shift is the amount we subtract from `l(x)` when x's assigned
1724 // cluster is the one with the largest shift -- otherwise the largest shift is the loose
1725 // bound donor for every non-assigned cluster.
1726 const HamerlyShiftTop2 top2 = prepareHamerlyGeometry(centroids, k, d);
1727 const T *shiftData = m_shiftEuclidean.data();
1728 const T *halfDistData = m_halfDistToNearestOther.data();
1729
1730 auto processRange = [&](std::size_t lo, std::size_t hi) noexcept {
1731 std::array<T, kHamerlyMaxK> distBuf{};
1732 for (std::size_t i = lo; i < hi; ++i) {
1733 const std::int32_t a = labelsData[i];
1734 if (a < 0 || std::cmp_greater_equal(a, k)) {
1735 continue;
1736 }
1737 const auto au = static_cast<std::size_t>(a);
1738 T ui = uData[i] + shiftData[au];
1739 T li = lData[i] - ((au == top2.argMax) ? top2.s2Max : top2.sMax);
1740
1741 const bool assignedByLower = ui <= li;
1742 const bool assignedByCenter = !assignedByLower && ui <= halfDistData[au];
1743 if (assignedByLower || assignedByCenter) {
1744 if (refreshAssignedMinDist) {
1745 const T *xi = xData + (i * d);
1746 const T *caRow = cData + (au * d);
1747 const T tightSq = math::detail::sqEuclideanRowPtr<T>(xi, caRow, d);
1748 minDistData[i] = tightSq;
1749 ui = std::sqrt(tightSq);
1750 }
1751 uData[i] = ui;
1752 lData[i] = li;
1753 continue;
1754 }
1755
1756 const T *xi = xData + (i * d);
1757 const T *caRow = cData + (au * d);
1758 const T tightSq = math::detail::sqEuclideanRowPtr<T>(xi, caRow, d);
1759 ui = std::sqrt(tightSq);
1760
1761 if (ui <= li) {
1762 uData[i] = ui;
1763 lData[i] = li;
1764 minDistData[i] = tightSq;
1765 continue;
1766 }
1767
1768 detail::sqEuclideanRowToBatch<T>(xi, cData, k, d, distBuf.data());
1769 T best = std::numeric_limits<T>::infinity();
1770 T second = std::numeric_limits<T>::infinity();
1771 std::int32_t bestIdx = 0;
1772 for (std::size_t j = 0; j < k; ++j) {
1773 const T v = distBuf[j];
1774 if (v < best) {
1775 second = best;
1776 best = v;
1777 bestIdx = static_cast<std::int32_t>(j);
1778 } else if (v < second) {
1779 second = v;
1780 }
1781 }
1782 labelsData[i] = bestIdx;
1783 minDistData[i] = best;
1784 uData[i] = std::sqrt(best);
1785 lData[i] = std::sqrt(second);
1786 }
1787 };
1788
1789 pool.parallelForBlocks(std::size_t{0}, n, std::size_t{0},
1790 [&](std::size_t lo, std::size_t hi) { processRange(lo, hi); });
1791 }
1792
1803 void runElkanAssignment(const NDArray<T, 2, Layout::Contig> &X,
1804 const NDArray<T, 2, Layout::Contig> &centroids,
1805 NDArray<std::int32_t, 1> &labels, math::Pool pool) noexcept {
1806 const std::size_t n = X.dim(0);
1807 const std::size_t d = X.dim(1);
1808 const std::size_t k = centroids.dim(0);
1809 if (n == 0 || d == 0 || k == 0 || m_elkanBounds.dim(0) != n || m_elkanBounds.dim(1) != k) {
1810 return;
1811 }
1812 const T *xData = X.data();
1813 const T *cData = centroids.data();
1814 T *uData = m_u.data();
1815 T *boundsData = m_elkanBounds.data();
1816 T *minDistData = m_minDistSq.data();
1817 std::int32_t *labelsData = labels.data();
1818
1819 // Per-cluster shift (Euclidean) used to update all bounds once at the top of the pass.
1820 T *shiftData = m_shiftEuclidean.data();
1821 for (std::size_t c = 0; c < k; ++c) {
1822 shiftData[c] = std::sqrt(m_shiftSq(c));
1823 }
1824
1825 // Pairwise centroid distances. Symmetric; fill upper triangle and mirror. `O(k^2 * d)`,
1826 // amortized against the @c n * k inner scan below.
1827 T *centerDistData = m_centerDist.data();
1828 T *halfDistData = m_halfDistToNearestOther.data();
1829 for (std::size_t c = 0; c < k; ++c) {
1830 centerDistData[(c * k) + c] = T{0};
1831 T nearest = std::numeric_limits<T>::infinity();
1832 for (std::size_t cp = 0; cp < k; ++cp) {
1833 if (cp == c) {
1834 continue;
1835 }
1836 T dist;
1837 if (cp > c) {
1838 const T dsq = math::detail::sqEuclideanRowPtr<T>(cData + (c * d), cData + (cp * d), d);
1839 dist = std::sqrt(dsq);
1840 centerDistData[(c * k) + cp] = dist;
1841 centerDistData[(cp * k) + c] = dist;
1842 } else {
1843 dist = centerDistData[(c * k) + cp];
1844 }
1845 if (dist < nearest) {
1846 nearest = dist;
1847 }
1848 }
1849 halfDistData[c] = T{0.5} * nearest;
1850 }
1851
1852 auto processRange = [&](std::size_t lo, std::size_t hi) noexcept {
1853 for (std::size_t i = lo; i < hi; ++i) {
1854 std::int32_t a = labelsData[i];
1855 if (a < 0 || std::cmp_greater_equal(a, k)) {
1856 continue;
1857 }
1858 auto au = static_cast<std::size_t>(a);
1859 T u = uData[i] + shiftData[au];
1860 T *lRow = boundsData + (i * k);
1861 // Bound-shift pass for this sample: looser lower bounds against all clusters. Done
1862 // inline so the per-sample walk touches the row exactly once.
1863 for (std::size_t c = 0; c < k; ++c) {
1864 T lnew = lRow[c] - shiftData[c];
1865 if (lnew < T{0}) {
1866 lnew = T{0};
1867 }
1868 lRow[c] = lnew;
1869 }
1870
1871 if (u <= halfDistData[au]) {
1872 uData[i] = u;
1873 continue;
1874 }
1875
1876 bool uTight = false;
1877 const T *xi = xData + (i * d);
1878 for (std::size_t c = 0; c < k; ++c) {
1879 if (c == au) {
1880 continue;
1881 }
1882 const T lc = lRow[c];
1883 const T half = T{0.5} * centerDistData[(au * k) + c];
1884 if (u <= lc || u <= half) {
1885 continue;
1886 }
1887 if (!uTight) {
1888 const T tightSq = math::detail::sqEuclideanRowPtr<T>(xi, cData + (au * d), d);
1889 u = std::sqrt(tightSq);
1890 minDistData[i] = tightSq;
1891 uTight = true;
1892 if (u <= lc || u <= half) {
1893 continue;
1894 }
1895 }
1896 const T dSq = math::detail::sqEuclideanRowPtr<T>(xi, cData + (c * d), d);
1897 const T dEuc = std::sqrt(dSq);
1898 lRow[c] = dEuc;
1899 if (dEuc < u) {
1900 au = c;
1901 a = static_cast<std::int32_t>(c);
1902 u = dEuc;
1903 minDistData[i] = dSq;
1904 }
1905 }
1906 uData[i] = u;
1907 labelsData[i] = a;
1908 }
1909 };
1910
1911 if (pool.shouldParallelize(n, 64, 2)) {
1912 pool.parallelForBlocks(std::size_t{0}, n, std::size_t{0},
1913 [&](std::size_t lo, std::size_t hi) { processRange(lo, hi); });
1914 } else {
1915 processRange(0, n);
1916 }
1917 }
1918
1919 NDArray<T, 2, Layout::Contig> m_centroidsOld;
1920 NDArray<T, 1> m_cSqNorms;
1921 NDArray<T, 2, Layout::Contig> m_sums;
1922 NDArray<std::int32_t, 1> m_counts;
1923 NDArray<T, 1> m_minDistSq;
1924 NDArray<T, 1> m_shiftSq;
1925 NDArray<T, 1> m_partialSums;
1926 NDArray<T, 1> m_partialComps;
1927 NDArray<std::int32_t, 1> m_partialCounts;
1928 NDArray<T, 1> m_foldComp;
1929 NDArray<T, 1> m_packedB;
1930 NDArray<T, 1> m_packedCSqNorms;
1931 NDArray<T, 2, Layout::Contig> m_distsChunk;
1932 NDArray<T, 1> m_gemmApArena;
1933 NDArray<T, 1> m_packedXAp;
1934 NDArray<T, 1> m_xNormsSq;
1935 NDArray<T, 1> m_varSum;
1936 NDArray<T, 1> m_varSumSq;
1937 NDArray<T, 1> m_varPartialSum;
1938 NDArray<T, 1> m_varPartialSumSq;
1941 NDArray<T, 1> m_u;
1943 NDArray<T, 1> m_l;
1945 NDArray<T, 1> m_shiftEuclidean;
1949 NDArray<T, 1> m_halfDistToNearestOther;
1953 NDArray<T, 2, Layout::Contig> m_elkanBounds;
1956 NDArray<T, 2, Layout::Contig> m_centerDist;
1957
1958 std::size_t m_n = 0;
1959 std::size_t m_d = 0;
1960 std::size_t m_k = 0;
1961 std::size_t m_workerCount = 0;
1962
1963 const T *m_packedXApCachedXData = nullptr;
1964 std::size_t m_packedXApCachedN = 0;
1965 std::size_t m_packedXApCachedD = 0;
1966 std::size_t m_packedXApChunkRows = 0;
1967 std::size_t m_packedXApPerChunk = 0;
1968
1969 // X-shape-stable cache: the mean column variance and m_xNormsSq depend only on X. The best-of
1970 // harness calls run() n_init times with the same X; recomputing per call is wasted.
1971 // Invalidated when (data ptr, n, d) changes; first call after a miss recomputes and caches.
1972 const T *m_xstatsCachedXData = nullptr;
1973 std::size_t m_xstatsCachedN = 0;
1974 std::size_t m_xstatsCachedD = 0;
1975 T m_xstatsCachedMeanVar{0};
1976};
1977
1978} // namespace clustering::kmeans
#define CLUSTERING_ALWAYS_ASSERT(cond)
Release-active assertion: evaluates cond in every build configuration.
Represents a multidimensional array (NDArray) of a fixed number of dimensions N and element type T.
Definition ndarray.h:136
size_t dim(std::size_t index) const noexcept
Returns the size of a specific dimension of the NDArray.
Definition ndarray.h:462
static NDArray borrow(T *ptr, std::array< std::size_t, N > shape) noexcept
Borrows a contiguous buffer as an NDArray without taking ownership.
Definition ndarray.h:571
const T * data() const noexcept
Provides read-only access to the internal data array.
Definition ndarray.h:504
void run(const NDArray< T, 2, Layout::Contig > &X, NDArray< T, 2, Layout::Contig > &centroids, std::size_t k, std::size_t maxIter, T tol, math::Pool pool, NDArray< std::int32_t, 1 > &outLabels, double &outInertia, std::size_t &outNIter, bool &outConverged)
Run the Lloyd loop against caller-seeded centroids.
static constexpr std::size_t kahanNThreshold
n threshold at which the centroid accumulator switches to Kahan-compensated summation.
constexpr std::size_t kDirectArgminMaxD
Maximum d for the direct-compute argmin hot path.
void sqEuclideanRowToBatch(const T *x, const T *candData, std::size_t L, std::size_t d, T *out) noexcept
Squared Euclidean distance from one x row to a batch of L candidate rows.
constexpr std::size_t pairwiseArgminMaxD
Maximum feature dimension for which the fused pairwiseArgminSqEuclidean driver is used.
Definition defaults.h:76
T sqNormRow(const NDArray< T, 2, LX > &X, std::size_t i) noexcept
Definition pairwise.h:205
constexpr std::size_t pairwiseArgminChunkRows
Chunk height used by the materialized argmin path when striping over n.
T sum(const NDArray< T, 1, L > &x) noexcept
Naive single-pass sum of a rank-1 array.
Definition reduce.h:25
void centroidShift(const NDArray< T, 2, Layout::Contig > &cOld, const NDArray< T, 2, Layout::Contig > &cNew, NDArray< T, 1 > &outShiftSq, Pool pool)
Per-row squared shift between two centroid matrices of identical shape.
Thin compile-time-templated wrapper around the underlying OwnedPool.
Definition thread.h:109
static std::size_t workerIndex() noexcept
Stable index of the calling worker thread within the owning pool.
Definition thread.h:131
OwnedPool * pool
Underlying pool, or nullptr to force serial execution.
Definition thread.h:111
std::size_t workerCount() const noexcept
Number of worker threads available, or 1 in serial mode.
Definition thread.h:118