Clustering
C++20 header-only: DBSCAN, HDBSCAN, k-means.
Loading...
Searching...
No Matches
afkmc2_seeder.h
Go to the documentation of this file.
1#pragma once
2
3#include <array>
4#include <cstddef>
5#include <cstdint>
6#include <cstring>
7#include <limits>
8#include <type_traits>
9
10#ifdef CLUSTERING_USE_AVX2
11#include <immintrin.h>
12#endif
13
15#include "clustering/math/detail/avx2_helpers.h"
16#include "clustering/math/detail/avx2_reductions.h"
17#include "clustering/math/detail/inverse_cdf_blocks.h"
18#include "clustering/math/detail/sq_distances_block.h"
19#include "clustering/math/detail/sq_distances_tile.h"
21#include "clustering/math/rng.h"
23#include "clustering/ndarray.h"
24
26
27using math::detail::affineInPlaceAvx2;
28using math::detail::bankWeightBlockSums;
29using math::detail::fillAvx2;
30#ifdef CLUSTERING_USE_AVX2
31using math::detail::inverseCdfPickInBlock8F32;
32#endif
33using math::detail::inverseCdfPickInRange;
34using math::detail::minDistBatchedAvx2F32;
35using math::detail::scaleAvx2;
36using math::detail::sqDistancesAosBlock;
37using math::detail::sqEuclideanRowPtr;
38using math::detail::sumReduceAvx2;
39
73template <class T> class AfkMc2Seeder {
74public:
75 static_assert(std::is_same_v<T, float> || std::is_same_v<T, double>,
76 "AfkMc2Seeder<T> requires T to be float or double");
77
78#ifdef CLUSTERING_KMEANS_AFKMC2_K_FLOOR
86 static constexpr std::size_t kFloor = CLUSTERING_KMEANS_AFKMC2_K_FLOOR;
87#else
89 static constexpr std::size_t kFloor = 100;
90#endif
91
92#ifdef CLUSTERING_KMEANS_AFKMC2_CHAIN_LENGTH
100 static constexpr std::size_t chainLengthDefault = CLUSTERING_KMEANS_AFKMC2_CHAIN_LENGTH;
101#else
103 static constexpr std::size_t chainLengthDefault = 64;
104#endif
105
107 : m_q({0}), m_aliasProb({0}), m_aliasIdx({0}), m_aliasSmall({0}), m_aliasLarge({0}),
108 m_yIdxBatch({0}), m_uBatch({0}), m_yQBatch({0}), m_yDistBatch({0}) {}
109
119 void run(const NDArray<T, 2, Layout::Contig> &X, std::size_t k, std::uint64_t seed,
120 math::Pool pool, NDArray<T, 2, Layout::Contig> &outCentroids) {
121 runChain(X, k, chainLengthDefault, seed, pool, outCentroids);
122 }
123
124private:
129 static constexpr std::size_t kCdfBlockElems = 8;
130
131 void runChain(const NDArray<T, 2, Layout::Contig> &X, std::size_t k, std::size_t m,
132 std::uint64_t seed, math::Pool pool, NDArray<T, 2, Layout::Contig> &outCentroids) {
133 const std::size_t n = X.dim(0);
134 const std::size_t d = X.dim(1);
135
136 CLUSTERING_ALWAYS_ASSERT(outCentroids.isMutable());
137 CLUSTERING_ALWAYS_ASSERT(outCentroids.dim(0) == k);
138 CLUSTERING_ALWAYS_ASSERT(outCentroids.dim(1) == d);
142
143 ensureShape(n, k, m);
144
145 math::pcg64 rng;
146 rng.seed(seed);
147
148 const T *xData = X.data();
149 T *centroidsData = outCentroids.data();
150 T *qData = m_q.data();
151
152 // Step 1: first centroid uniformly.
153 const auto first = static_cast<std::size_t>(math::randUniformU64(rng) % n);
154 std::memcpy(centroidsData, xData + (first * d), d * sizeof(T));
155
156 if (k == 1) {
157 return;
158 }
159
160 // Step 2: q-precompute. Squared distance from each point to the first centroid drives the
161 // data-proximal half of the proposal density; the 1/n floor guarantees ergodicity even at
162 // sumD2==0. Distance scan + sumReduce optionally fan out across pool when the per-worker
163 // op budget amortises the spawn cost.
164 const T *firstRow = centroidsData;
165 const std::size_t qOps = n * d;
166 if (pool.shouldParallelizeWork(qOps, /*minOpsPerWorker=*/std::size_t{1} << 15)) {
167 qPrecomputeParallel(xData, firstRow, n, d, qData, pool);
168 } else {
169 sqDistancesAosBlock<T>(firstRow, xData, n, d, qData);
170 }
171
172 T sumD2;
173 if (pool.shouldParallelizeWork(n, /*minOpsPerWorker=*/std::size_t{1} << 17)) {
174 sumD2 = sumReduceParallel(qData, n, pool);
175 } else {
176 sumD2 = sumReduceAvx2(qData, n);
177 }
178
179 const T invN = T{1} / static_cast<T>(n);
180 if (sumD2 > T{0}) {
181 const T invSum = T{1} / sumD2;
182 affineInPlaceAvx2(qData, n, T{0.5} * invSum, T{0.5} * invN);
183 } else {
184 // Degenerate: every point coincides with c_1. Fall back to uniform so the chain stays
185 // ergodic over the point set.
186 fillAvx2(qData, n, invN);
187 }
188
189 // Walker alias table samples in `O(1)` but its build pass has random writes into
190 // `prob[l]` whose cost grows with `n` once `prob` overflows L2; the block-sum bank is a
191 // throughput-bound streaming pass plus a short prefix over the per-block totals. They
192 // break even when the chain's per-call sample budget amortises the alias build's `O(n)`
193 // random-access overhead. The shape gate below routes small-`n` workloads to alias and
194 // large-`n` workloads to block search + in-block inverse-CDF walk.
195 const std::size_t chainSamples = (k - 1) * (m + 1);
196 const bool useAlias = chainSamples * 5 > n;
197 const std::size_t qBlocks = (n + kCdfBlockElems - 1) / kCdfBlockElems;
198 if (useAlias) {
199 buildAliasTable(qData, n);
200 } else {
201 T *blockPrefix = m_aliasProb.data();
202 bankWeightBlockSums(qData, n, kCdfBlockElems, blockPrefix);
203 T running = T{0};
204 for (std::size_t b = 0; b < qBlocks; ++b) {
205 running += blockPrefix[b];
206 blockPrefix[b] = running;
207 }
208 }
209
210 // Step 3: for each remaining centroid, pre-sample the chain's `m+1` proposals plus their
211 // accept-uniforms in one PRNG-deterministic order, then dispatch the proposal-vs-chosen
212 // distance scan to the 4q x 2c tile kernel. The chain walk consumes the precomputed
213 // distances and uniforms with `O(1)` arithmetic per step.
214 std::size_t *yIdxBatch = m_yIdxBatch.data();
215 T *uBatch = m_uBatch.data();
216 T *yQBatch = m_yQBatch.data();
217 T *yDistBatch = m_yDistBatch.data();
218
219 for (std::size_t c = 1; c < k; ++c) {
220 // Pre-sample m+1 proposals (yIdxBatch[0] is the chain's initial xIdx).
221 if (useAlias) {
222 for (std::size_t t = 0; t <= m; ++t) {
223 yIdxBatch[t] = sampleFromAlias(rng, n);
224 }
225 } else {
226 sampleBatchFromBlocks(rng, qData, m_aliasProb.data(), n, qBlocks, m + 1, yIdxBatch);
227 }
228 for (std::size_t t = 0; t < m; ++t) {
229 uBatch[t] = math::randUnit<T>(rng);
230 }
231
232 // Compute the per-proposal min distance to the chosen-centroid block via the 4q x 2c
233 // tile kernel. Centroid rows are loaded once per query block of 4 instead of once per
234 // chain step, cutting centroid-side load traffic by `>= 4x`.
235 minDistBatchedFromIdx(xData, d, yIdxBatch, m + 1, centroidsData, c, yDistBatch);
236
237 // Gather the per-proposal q values via index lookup.
238 for (std::size_t t = 0; t <= m; ++t) {
239 yQBatch[t] = qData[yIdxBatch[t]];
240 }
241
242 // Walk the chain serially. Acceptance ratio `(yDist / yQ) / (xDist / xQ)` reordered as
243 // `yDist * xQ` vs `xDist * yQ` to skip the division. Draw u every step from the
244 // precomputed batch so the PRNG sequence depends only on `(seed, n, k, m)` and never on
245 // branch outcomes inside the chain.
246 std::size_t xIdx = yIdxBatch[0];
247 T xDist = yDistBatch[0];
248 T xQ = yQBatch[0];
249 for (std::size_t step = 0; step < m; ++step) {
250 const T yDist = yDistBatch[step + 1];
251 const T yQ = yQBatch[step + 1];
252 const T u = uBatch[step];
253
254 const T numer = yDist * xQ;
255 const T denom = xDist * yQ;
256 const bool accept = (denom <= T{0}) || ((u * denom) < numer);
257
258 if (accept) {
259 xIdx = yIdxBatch[step + 1];
260 xDist = yDist;
261 xQ = yQ;
262 }
263 }
264
265 std::memcpy(centroidsData + (c * d), xData + (xIdx * d), d * sizeof(T));
266 }
267 }
268
269 void ensureShape(std::size_t n, std::size_t k, std::size_t m) {
270 if (m_q.dim(0) != n) {
271 m_q = NDArray<T, 1>({n});
272 }
273 if (m_aliasProb.dim(0) != n) {
274 m_aliasProb = NDArray<T, 1>({n});
275 }
276 if (m_aliasIdx.dim(0) != n) {
277 m_aliasIdx = NDArray<std::size_t, 1>({n});
278 }
279 if (m_aliasSmall.dim(0) != n) {
280 m_aliasSmall = NDArray<std::size_t, 1>({n});
281 }
282 if (m_aliasLarge.dim(0) != n) {
283 m_aliasLarge = NDArray<std::size_t, 1>({n});
284 }
285 if (m_yIdxBatch.dim(0) != m + 1) {
286 m_yIdxBatch = NDArray<std::size_t, 1>({m + 1});
287 }
288 if (m_uBatch.dim(0) != m) {
289 m_uBatch = NDArray<T, 1>({m});
290 }
291 if (m_yQBatch.dim(0) != m + 1) {
292 m_yQBatch = NDArray<T, 1>({m + 1});
293 }
294 if (m_yDistBatch.dim(0) != m + 1) {
295 m_yDistBatch = NDArray<T, 1>({m + 1});
296 }
297 (void)k;
298 }
299
304 void buildAliasTable(const T *qSrc, std::size_t n) noexcept {
305 T *prob = m_aliasProb.data();
306 std::size_t *alias = m_aliasIdx.data();
307 std::size_t *smallStack = m_aliasSmall.data();
308 std::size_t *largeStack = m_aliasLarge.data();
309
310 // Scale q so each bucket's "expected mass" is n. After scaling, prob[i] in [0, n] maps
311 // directly to acceptance probability after the partition step rescales by 1.
312 const T total = sumReduceAvx2(qSrc, n);
313 CLUSTERING_ALWAYS_ASSERT(total > T{0});
314 const T scale = static_cast<T>(n) / total;
315 scaleAvx2(qSrc, n, scale, prob);
316
317 std::size_t numSmall = 0;
318 std::size_t numLarge = 0;
319 for (std::size_t i = 0; i < n; ++i) {
320 if (prob[i] < T{1}) {
321 smallStack[numSmall++] = i;
322 } else {
323 largeStack[numLarge++] = i;
324 }
325 }
326
327 while (numSmall > 0 && numLarge > 0) {
328 const std::size_t s = smallStack[--numSmall];
329 const std::size_t l = largeStack[--numLarge];
330 // `prob[s]` already in `[0, 1)`; it is the acceptance probability for bucket s. The
331 // alias bucket is l, which absorbs the residual mass `1 - prob[s]`.
332 alias[s] = l;
333 const T residual = prob[l] - (T{1} - prob[s]);
334 prob[l] = residual;
335 if (residual < T{1}) {
336 smallStack[numSmall++] = l;
337 } else {
338 largeStack[numLarge++] = l;
339 }
340 }
341 // Drain any remaining buckets due to FP rounding; their acceptance probability is 1.
342 while (numLarge > 0) {
343 const std::size_t l = largeStack[--numLarge];
344 prob[l] = T{1};
345 alias[l] = l;
346 }
347 while (numSmall > 0) {
348 const std::size_t s = smallStack[--numSmall];
349 prob[s] = T{1};
350 alias[s] = s;
351 }
352 }
353
355 [[gnu::always_inline]] std::size_t sampleFromAlias(math::pcg64 &rng, std::size_t n) noexcept {
356 const std::uint64_t r = math::randUniformU64(rng);
357 const auto i = static_cast<std::size_t>(r % static_cast<std::uint64_t>(n));
358 const T u = math::randUnit<T>(rng);
359 return (u < m_aliasProb.data()[i]) ? i : m_aliasIdx.data()[i];
360 }
361
370 void sampleBatchFromBlocks(math::pcg64 &rng, const T *q, const T *blockPrefix, std::size_t n,
371 std::size_t qBlocks, std::size_t count, std::size_t *outIdx) noexcept {
372 const T total = blockPrefix[qBlocks - 1];
373 T *u = m_yDistBatch.data();
374 for (std::size_t t = 0; t < count; ++t) {
375 u[t] = math::randUnit<T>(rng) * total;
376 }
377 for (std::size_t t = 0; t < count; ++t) {
378 outIdx[t] = 0;
379 }
380 // Branchless range halving: the live range length depends only on itself, so every draw
381 // sits at the same level and the per-level probe loop stays uniform.
382 std::size_t len = qBlocks + 1;
383 while (len > 1) {
384 const std::size_t half = len / 2;
385 for (std::size_t t = 0; t < count; ++t) {
386 outIdx[t] += (blockPrefix[outIdx[t] + half - 1] <= u[t]) ? half : 0;
387 }
388 len -= half;
389 }
390 for (std::size_t t = 0; t < count; ++t) {
391 std::size_t b = outIdx[t];
392 if (b >= qBlocks) {
393 b = qBlocks - 1;
394 }
395 const T rem = u[t] - ((b > 0) ? blockPrefix[b - 1] : T{0});
396 const std::size_t lo = b * kCdfBlockElems;
397 if (n - lo >= kCdfBlockElems) {
398#ifdef CLUSTERING_USE_AVX2
399 if constexpr (std::is_same_v<T, float>) {
400 outIdx[t] = lo + inverseCdfPickInBlock8F32(q + lo, rem);
401 continue;
402 }
403#endif
404 outIdx[t] = inverseCdfPickInRange(q, lo, lo + kCdfBlockElems, rem);
405 } else {
406 outIdx[t] = inverseCdfPickInRange(q, lo, n, rem);
407 }
408 }
409 }
410
413 [[gnu::always_inline]] void minDistBatchedFromIdx(const T *xData, std::size_t d,
414 const std::size_t *yIdx, std::size_t qCount,
415 const T *centroids, std::size_t cCount,
416 T *out) noexcept {
417#ifdef CLUSTERING_USE_AVX2
418 if constexpr (std::is_same_v<T, float>) {
419 minDistBatchedAvx2F32(xData, d, yIdx, qCount, centroids, cCount, out);
420 return;
421 }
422#endif
423 for (std::size_t t = 0; t < qCount; ++t) {
424 const T *qrow = xData + (yIdx[t] * d);
425 T best = std::numeric_limits<T>::infinity();
426 // Block of 4 to amortise the hsum.
427 alignas(16) std::array<T, 4> blockOut{};
428 std::size_t j = 0;
429 for (; j + 4 <= cCount; j += 4) {
430 sqDistancesAosBlock<T>(qrow, centroids + (j * d), 4, d, blockOut.data());
431 for (std::size_t r = 0; r < 4; ++r) {
432 if (blockOut[r] < best) {
433 best = blockOut[r];
434 }
435 }
436 }
437 for (; j < cCount; ++j) {
438 const T dsq = sqEuclideanRowPtr(qrow, centroids + (j * d), d);
439 if (dsq < best) {
440 best = dsq;
441 }
442 }
443 out[t] = best;
444 }
445 }
446
450 void qPrecomputeParallel(const T *xData, const T *firstRow, std::size_t n, std::size_t d,
451 T *qData, math::Pool pool) noexcept {
452 pool.parallelForExactBlocks(std::size_t{0}, n, pool.workerCount(),
453 [&](std::size_t startIdx, std::size_t endIdx) noexcept {
454 const std::size_t cnt = endIdx - startIdx;
455 sqDistancesAosBlock<T>(firstRow, xData + (startIdx * d), cnt, d,
456 qData + startIdx);
457 });
458 }
459
461 T sumReduceParallel(const T *p, std::size_t n, math::Pool pool) noexcept {
462 const std::size_t workers = pool.workerCount();
463 std::array<T, 64> partials{};
464 CLUSTERING_ALWAYS_ASSERT(workers <= 64);
465 pool.parallelForExactBlocksWithSlot<citor::HintsDefaults>(
466 std::size_t{0}, n, workers,
467 [&, p](std::size_t startIdx, std::size_t endIdx, std::size_t slot) noexcept {
468 partials[slot] = sumReduceAvx2(p + startIdx, endIdx - startIdx);
469 });
470 T s = T{0};
471 for (std::size_t w = 0; w < workers; ++w) {
472 s += partials[w];
473 }
474 return s;
475 }
476
477 NDArray<T, 1> m_q;
478 NDArray<T, 1> m_aliasProb;
479 NDArray<std::size_t, 1> m_aliasIdx;
480 NDArray<std::size_t, 1> m_aliasSmall;
481 NDArray<std::size_t, 1> m_aliasLarge;
482 NDArray<std::size_t, 1> m_yIdxBatch;
483 NDArray<T, 1> m_uBatch;
484 NDArray<T, 1> m_yQBatch;
485 NDArray<T, 1> m_yDistBatch;
486};
487
488} // 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
const T * data() const noexcept
Provides read-only access to the internal data array.
Definition ndarray.h:504
bool isMutable() const noexcept
Reports whether writes through operator(), Accessor, or flatIndex are allowed.
Definition ndarray.h:489
void run(const NDArray< T, 2, Layout::Contig > &X, std::size_t k, std::uint64_t seed, math::Pool pool, NDArray< T, 2, Layout::Contig > &outCentroids)
Seed k centroids from X into outCentroids.
static constexpr std::size_t chainLengthDefault
Default Markov-chain length per centroid pick.
static constexpr std::size_t kFloor
Minimum k below which the AFK-MC2 chain's log-k bound is too loose to win.
T randUnit(Rng &rng) noexcept
Draw a uniform variate in the half-open unit interval [0, 1).
Definition rng.h:152
std::uint64_t randUniformU64(Rng &rng) noexcept
Draw a 64-bit unsigned integer uniformly at random from the full u64 range.
Definition rng.h:139
Thin compile-time-templated wrapper around the underlying OwnedPool.
Definition thread.h:109
void parallelForExactBlocks(std::size_t first, std::size_t last, std::size_t numBlocks, Body body)
Run body in parallel with exactly numBlocks contiguous ranges.
Definition thread.h:268
std::size_t workerCount() const noexcept
Number of worker threads available, or 1 in serial mode.
Definition thread.h:118
bool shouldParallelizeWork(std::size_t totalOps, std::size_t minOpsPerWorker=std::size_t{1}<< 15) const noexcept
Decide whether totalOps warrants parallel dispatch, based on work volume.
Definition thread.h:168
void parallelForExactBlocksWithSlot(std::size_t first, std::size_t last, std::size_t numBlocks, Body body)
Slot-aware variant of parallelForExactBlocks.
Definition thread.h:307
128-bit state for the PCG-XSL-RR 64-bit output generator (Melissa O'Neill).
Definition rng.h:30
void seed(std::uint64_t seedValue, std::uint64_t stream=0) noexcept
Initialize the generator per PCG's canonical seeding procedure.
Definition rng.h:46