Clustering
C++20 header-only: DBSCAN, HDBSCAN, k-means.
Loading...
Searching...
No Matches
prim_mst_backend.h
Go to the documentation of this file.
1#pragma once
2
3#include <algorithm>
4#include <array>
5#include <atomic>
6#include <cstddef>
7#include <cstdint>
8#include <future>
9#include <limits>
10#include <thread>
11#include <type_traits>
12#include <utility>
13#include <vector>
14
18#include "clustering/math/detail/avx2_helpers.h"
19#include "clustering/math/detail/sq_distances_block.h"
21#include "clustering/ndarray.h"
22
23namespace clustering::hdbscan {
24
34inline constexpr std::size_t kPrimMaxN = std::size_t{16384};
35
39inline constexpr std::size_t kPrimMrdMatrixByteBudget = kPrimMaxN * kPrimMaxN * sizeof(float);
40
51inline constexpr std::size_t kPrimDenseCoreMinN = 1024;
52inline constexpr std::size_t kPrimDenseCoreMinD = 17;
53inline constexpr std::size_t kPrimDenseCoreMaxMinSamples = 64;
54inline constexpr std::size_t kPrimPersistentRelaxMinWorkers = 8;
55inline constexpr std::size_t kPrimPersistentRelaxMinOpsPerWorker = std::size_t{1} << 14;
56
84template <class T> class PrimMstBackend {
85 static_assert(std::is_same_v<T, float>,
86 "PrimMstBackend<T> supports only float; a double specialization is out of scope.");
87
88public:
89 PrimMstBackend() = default;
90
104 void run(const NDArray<T, 2> &X, std::size_t minSamples, math::Pool pool, MstOutput<T> &out) {
105 const std::size_t n = X.dim(0);
106 const std::size_t d = X.dim(1);
107 CLUSTERING_ALWAYS_ASSERT(minSamples >= 1);
108 CLUSTERING_ALWAYS_ASSERT(minSamples < n);
109
110 // Refuse @c n that would push the `O(n^2 * d)` inner work past the dispatcher's intended
111 // Prim window. Phrased as `n <= kNsqBudget` / n rather than @c n*n <= kNsqBudget to avoid
112 // the intermediate overflowing @c std::size_t at large @c n. Fires before any allocation so
113 // out-of-budget callers surface deterministically.
114 constexpr std::size_t kNsqBudget = kPrimMrdMatrixByteBudget / sizeof(T);
115 CLUSTERING_ALWAYS_ASSERT(n <= kNsqBudget / n);
116
117 out.edges.clear();
118 out.edges.reserve(n - 1);
119 out.coreDistances = NDArray<T, 1>(std::array<std::size_t, 1>{n});
120 T *coreDistData = out.coreDistances.data();
121 const T *xData = X.data();
122 const bool useDenseCore = shouldUseDenseCore(n, d, minSamples);
123 // Row @c i starts at @c xData + i*d, so every row is 32-byte aligned iff the base pointer
124 // is aligned and the row stride @c d*sizeof(T) is a multiple of 32. Either condition can
125 // fail independently: NumPy buffers only guarantee element alignment, and @c d is caller-
126 // driven. When both hold we can use the strict-aligned dot kernel; otherwise the generic
127 // kernel's per-operand alignment check is required to stay correct.
128 const bool rowsAligned32 =
129 X.template isAligned<32>() && (d % (std::size_t{32} / sizeof(T)) == 0);
130
131 std::vector<T> rowNorms;
132 if (useDenseCore) {
133 rowNorms.resize(n);
134 for (std::size_t i = 0; i < n; ++i) {
135 const T *row = xData + (i * d);
136 rowNorms[i] = rowsAligned32 ? math::detail::dotRowAligned32Ptr(row, row, d)
137 : math::detail::dotRowPtr(row, row, d);
138 }
139 computeDenseCoreDistances(X, rowNorms, minSamples, rowsAligned32, pool, coreDistData);
140 } else {
141 // Shapes that fail @c shouldUseDenseCore take the KDTree kNN path: the dense symmetric
142 // scan does not amortise at small @c n, low @c d, or large @c minSamples (where the per-
143 // update top-@c k rescan dominates).
144 const KDTree<T> tree(X);
145 const auto kSigned = static_cast<std::int32_t>(minSamples);
146 auto [knnIdx, knnSqDist] = tree.knnQuery(kSigned, pool);
147 (void)knnIdx;
148 for (std::size_t i = 0; i < n; ++i) {
149 coreDistData[i] = knnSqDist(i, minSamples - 1);
150 }
151 }
152
153 // Phase 2: streaming Prim. Maintain `edgeWeight[v]` = best-known incident MRD weight to
154 // the growing tree, `parent[v]` = the in-tree vertex realising that weight, and a visited
155 // bitmap. Each iteration picks the smallest-weight unvisited @c target via a linear scan,
156 // emits the edge `(parent[target], target, edgeWeight[target])`, then relaxes every other
157 // unvisited @c v by recomputing `sqDist(target, v)` and lifting to MRD.
158 std::vector<std::uint8_t> visited(n, std::uint8_t{0});
159 std::vector<std::int32_t> parent(n, std::int32_t{0});
160 std::vector<T> edgeWeight(n, std::numeric_limits<T>::max());
161
162 auto sqDistance = [&](const T *rowT, std::size_t tIdx, std::size_t v) noexcept {
163 if (useDenseCore) {
164 const T *rowV = xData + (v * d);
165 const T dot = rowsAligned32 ? math::detail::dotRowAligned32Ptr(rowT, rowV, d)
166 : math::detail::dotRowPtr(rowT, rowV, d);
167 return math::detail::sqEuclideanFromDot(rowNorms[tIdx], rowNorms[v], dot);
168 }
169 return math::detail::sqEuclideanRowPtr(rowT, xData + (v * d), d);
170 };
171
172 auto relaxRange = [&](std::size_t lo, std::size_t hi, std::int32_t target, std::size_t tIdx,
173 T coreT, const T *rowT) noexcept {
174 for (std::size_t v = lo; v < hi; ++v) {
175 if (visited[v] != 0U) {
176 continue;
177 }
178 const T sq = sqDistance(rowT, tIdx, v);
179 T w = sq;
180 if (coreT > w) {
181 w = coreT;
182 }
183 const T coreV = coreDistData[v];
184 if (coreV > w) {
185 w = coreV;
186 }
187 if (w < edgeWeight[v]) {
188 parent[v] = target;
189 edgeWeight[v] = w;
190 }
191 }
192 };
193
194 auto relaxRangeAndFindNext = [&](std::size_t lo, std::size_t hi, std::int32_t target,
195 std::size_t tIdx, T coreT,
196 const T *rowT) noexcept -> std::pair<std::int32_t, T> {
197 std::int32_t bestV = -1;
198 T bestW = std::numeric_limits<T>::max();
199 for (std::size_t v = lo; v < hi; ++v) {
200 if (visited[v] != 0U) {
201 continue;
202 }
203 const T sq = sqDistance(rowT, tIdx, v);
204 T w = sq;
205 if (coreT > w) {
206 w = coreT;
207 }
208 const T coreV = coreDistData[v];
209 if (coreV > w) {
210 w = coreV;
211 }
212 if (w < edgeWeight[v]) {
213 parent[v] = target;
214 edgeWeight[v] = w;
215 }
216 if (edgeWeight[v] < bestW) {
217 bestW = edgeWeight[v];
218 bestV = static_cast<std::int32_t>(v);
219 }
220 }
221 return {bestV, bestW};
222 };
223
224 auto findNext = [&]() noexcept -> std::pair<std::int32_t, T> {
225 std::int32_t bestV = -1;
226 T bestW = std::numeric_limits<T>::max();
227 for (std::size_t v = 0; v < n; ++v) {
228 if (visited[v] != 0U) {
229 continue;
230 }
231 if (edgeWeight[v] < bestW) {
232 bestW = edgeWeight[v];
233 bestV = static_cast<std::int32_t>(v);
234 }
235 }
236 return {bestV, bestW};
237 };
238
239 auto persistentRelaxFrom = [&]() -> bool {
240 if (!shouldUsePersistentParallelRelax(n, d, useDenseCore, pool)) {
241 return false;
242 }
243
244 const std::size_t participantCount = pool.workerCount();
245 // Per-slot reduction state: one row per participant, padded to a cache line so
246 // adjacent slots' updates land on disjoint cache lines.
247 struct alignas(64) LocalBest {
248 std::int32_t vertex = -1;
249 T weight = std::numeric_limits<T>::max();
250 std::int32_t pad0 = 0; // padding so sizeof(LocalBest) covers the cache line
251 };
252 std::vector<LocalBest> localBest(participantCount);
253
254 auto blockBegin = [&](std::size_t id) noexcept { return (n * id) / participantCount; };
255 auto blockEnd = [&](std::size_t id) noexcept { return (n * (id + 1)) / participantCount; };
256 auto relaxBlock = [&](std::size_t id,
257 std::int32_t target) noexcept -> std::pair<std::int32_t, T> {
258 const auto tIdx = static_cast<std::size_t>(target);
259 const T coreT = coreDistData[tIdx];
260 const T *const rowT = xData + (tIdx * d);
261 std::int32_t bestV = -1;
262 T bestW = std::numeric_limits<T>::max();
263 for (std::size_t v = blockBegin(id); v < blockEnd(id); ++v) {
264 if (visited[v] != 0U) {
265 continue;
266 }
267 const T sq = sqDistance(rowT, tIdx, v);
268 T w = sq;
269 if (coreT > w) {
270 w = coreT;
271 }
272 const T coreV = coreDistData[v];
273 if (coreV > w) {
274 w = coreV;
275 }
276 if (w < edgeWeight[v]) {
277 parent[v] = target;
278 edgeWeight[v] = w;
279 }
280 if (edgeWeight[v] < bestW) {
281 bestW = edgeWeight[v];
282 bestV = static_cast<std::int32_t>(v);
283 }
284 }
285 return {bestV, bestW};
286 };
287
288 auto reduceBest = [&]() noexcept -> std::pair<std::int32_t, T> {
289 std::int32_t bestV = -1;
290 T bestW = std::numeric_limits<T>::max();
291 for (const LocalBest &candidate : localBest) {
292 if (candidate.vertex >= 0 && candidate.weight < bestW) {
293 bestW = candidate.weight;
294 bestV = candidate.vertex;
295 }
296 }
297 return {bestV, bestW};
298 };
299
300 // Phase-by-phase persistent-worker plex: phase 0 seeds vertex 0 into the tree and
301 // relaxes its neighbours; each subsequent phase first commits the previous phase's
302 // argmin into the spanning tree (producer-serial, via @c prePhaseFn) and then relaxes
303 // the new target across every slot. The plex's per-slot fan-out replaces the manual
304 // submit_task / spin-wait dance the BS port used; on the citor backend this rides
305 // the persistent-plex phase epoch with no per-phase futex round-trip.
306 visited[0] = 1U;
307 edgeWeight[0] = T{0};
308 std::int32_t phaseTarget = 0;
309
310 auto prePhase = [&](std::size_t phaseIdx) noexcept {
311 if (phaseIdx == 0) {
312 phaseTarget = 0;
313 return;
314 }
315 // Commit the previous phase's argmin into the tree.
316 auto [bv, bw] = reduceBest();
318 const auto bIdx = static_cast<std::size_t>(bv);
319 visited[bIdx] = 1U;
320 out.edges.push_back(MstEdge<T>{parent[bIdx], bv, bw});
321 phaseTarget = bv;
322 };
323
324 auto phaseFn = [&](std::size_t /*phaseIdx*/, std::uint32_t slot, std::size_t /*lo*/,
325 std::size_t /*hi*/, void * /*tlsArena*/ = nullptr) noexcept {
326 auto [bv, bw] = relaxBlock(slot, phaseTarget);
327 localBest[slot].vertex = bv;
328 localBest[slot].weight = bw;
329 };
330
331 const std::size_t totalPhases = n - 1;
332 pool.parallelRunPlex<citor::HintsDefaults>(totalPhases, n, std::move(phaseFn),
333 std::move(prePhase));
334 // Final commit: the last phase's argmin closes the spanning tree.
335 auto [bv, bw] = reduceBest();
337 const auto bIdx = static_cast<std::size_t>(bv);
338 visited[bIdx] = 1U;
339 out.edges.push_back(MstEdge<T>{parent[bIdx], bv, bw});
340 return true;
341 };
342
343 if (persistentRelaxFrom()) {
344 return;
345 }
346
347 auto relaxFrom = [&](std::int32_t target) noexcept -> std::pair<std::int32_t, T> {
348 const auto tIdx = static_cast<std::size_t>(target);
349 const T coreT = coreDistData[tIdx];
350 const T *rowT = xData + (tIdx * d);
351 // Per-iter parallel dispatch: the gate uses the per-worker op budget `(n*d / nWorkers)`
352 // so very small @c n stays serial and avoids submit_blocks overhead.
353 if (pool.shouldParallelizeWork(n * d)) {
354 pool.parallelForBlocks<citor::HintsDefaults>(
355 std::size_t{0}, n, std::size_t{0},
356 [&](std::size_t lo, std::size_t hi) { relaxRange(lo, hi, target, tIdx, coreT, rowT); });
357 return findNext();
358 }
359 return relaxRangeAndFindNext(0, n, target, tIdx, coreT, rowT);
360 };
361
362 // Seed: vertex 0 is in the tree with weight 0. The first relax populates @c edgeWeight for
363 // every other vertex so the first argmin scan has finite values.
364 visited[0] = 1U;
365 edgeWeight[0] = T{0};
366 auto [nextV, nextW] = relaxFrom(static_cast<std::int32_t>(0));
367
368 while (out.edges.size() + 1 < n) {
369 // The graph is complete (every pair has a finite MRD), so on a connected workload the
370 // argmin always finds a finite entry. Asserting here flags any contract violation that
371 // would otherwise leave the spanning tree short of @c n - 1 edges.
372 CLUSTERING_ALWAYS_ASSERT(nextV >= 0);
373
374 const auto bIdx = static_cast<std::size_t>(nextV);
375 visited[bIdx] = 1U;
376 out.edges.push_back(MstEdge<T>{parent[bIdx], nextV, nextW});
377
378 if (out.edges.size() + 1 == n) {
379 break;
380 }
381 auto next = relaxFrom(nextV);
382 nextV = next.first;
383 nextW = next.second;
384 }
385 }
386
387private:
388 [[nodiscard]] static constexpr bool shouldUseDenseCore(std::size_t n, std::size_t d,
389 std::size_t minSamples) noexcept {
390 return n >= kPrimDenseCoreMinN && d >= kPrimDenseCoreMinD &&
391 minSamples <= kPrimDenseCoreMaxMinSamples;
392 }
393
394 [[nodiscard]] static bool shouldUsePersistentParallelRelax(std::size_t n, std::size_t d,
395 bool useDenseCore,
396 math::Pool pool) noexcept {
397 return useDenseCore && pool.workerCount() >= kPrimPersistentRelaxMinWorkers &&
398 pool.shouldParallelizeWork(n * d, kPrimPersistentRelaxMinOpsPerWorker);
399 }
400
401 static void spinPause() noexcept {
402#ifdef CLUSTERING_USE_AVX2
403 _mm_pause();
404#else
405 std::this_thread::yield();
406#endif
407 }
408
415 static void updateTopK(T *topK, std::vector<std::size_t> &worstSlot, std::size_t minSamples,
416 std::size_t row, T sq) noexcept {
417 T *const rowTopK = topK + (row * minSamples);
418 std::size_t worst = worstSlot[row];
419 if (!(sq < rowTopK[worst])) {
420 return;
421 }
422 rowTopK[worst] = sq;
423 worst = 0;
424 T worstValue = rowTopK[0];
425 for (std::size_t s = 1; s < minSamples; ++s) {
426 if (rowTopK[s] > worstValue) {
427 worstValue = rowTopK[s];
428 worst = s;
429 }
430 }
431 worstSlot[row] = worst;
432 }
433
440 static void computeDenseCoreDistances(const NDArray<T, 2> &X, const std::vector<T> &rowNorms,
441 std::size_t minSamples, bool rowsAligned32, math::Pool pool,
442 T *coreDistData) {
443 const std::size_t n = X.dim(0);
444 const std::size_t d = X.dim(1);
445 const T *const xData = X.data();
446 std::vector<T> topK(n * minSamples, std::numeric_limits<T>::max());
447 std::vector<std::size_t> worstSlot(n, 0);
448
449 // With enough workers, row-independent scans win despite computing each pair twice: every row
450 // owns its top-k state, so the pool path has no cross-row writes and can reuse the batched
451 // four-neighbour distance kernel that amortises AVX2 horizontal sums.
452 if (pool.workerCount() >= 4 && pool.shouldParallelizeWork(n * n * d)) {
453 pool.parallelForBlocks<citor::HintsDefaults>(
454 std::size_t{0}, n, std::size_t{0}, [&](std::size_t lo, std::size_t hi) {
455 computeDenseCoreDistancesRows(X, minSamples, lo, hi, topK.data(), worstSlot);
456 });
457 for (std::size_t i = 0; i < n; ++i) {
458 coreDistData[i] = topK[(i * minSamples) + worstSlot[i]];
459 }
460 return;
461 }
462
463 for (std::size_t i = 0; i < n; ++i) {
464 const T *const rowI = xData + (i * d);
465 const T normI = rowNorms[i];
466 for (std::size_t j = i + 1; j < n; ++j) {
467 const T *const rowJ = xData + (j * d);
468 const T dot = rowsAligned32 ? math::detail::dotRowAligned32Ptr(rowI, rowJ, d)
469 : math::detail::dotRowPtr(rowI, rowJ, d);
470 const T sq = math::detail::sqEuclideanFromDot(normI, rowNorms[j], dot);
471 updateTopK(topK.data(), worstSlot, minSamples, i, sq);
472 updateTopK(topK.data(), worstSlot, minSamples, j, sq);
473 }
474 }
475
476 for (std::size_t i = 0; i < n; ++i) {
477 coreDistData[i] = topK[(i * minSamples) + worstSlot[i]];
478 }
479 }
480
481 static void computeDenseCoreDistancesRows(const NDArray<T, 2> &X, std::size_t minSamples,
482 std::size_t lo, std::size_t hi, T *topK,
483 std::vector<std::size_t> &worstSlot) noexcept {
484 constexpr std::size_t kBlockRows = 64;
485 const std::size_t n = X.dim(0);
486 const std::size_t d = X.dim(1);
487 const T *const xData = X.data();
488 std::array<T, kBlockRows> distances{};
489
490 for (std::size_t i = lo; i < hi; ++i) {
491 const T *const rowI = xData + (i * d);
492 for (std::size_t base = 0; base < n; base += kBlockRows) {
493 const std::size_t count = std::min(kBlockRows, n - base);
494 math::detail::sqDistancesAosBlock(rowI, xData + (base * d), count, d, distances.data());
495 for (std::size_t offset = 0; offset < count; ++offset) {
496 const std::size_t j = base + offset;
497 if (j != i) {
498 updateTopK(topK, worstSlot, minSamples, i, distances[offset]);
499 }
500 }
501 }
502 }
503 }
504};
505
506} // namespace clustering::hdbscan
#define CLUSTERING_ALWAYS_ASSERT(cond)
Release-active assertion: evaluates cond in every build configuration.
Implements a KDTree data structure.
Definition kdtree.h:93
std::pair< NDArray< std::int32_t, 2 >, NDArray< T, 2 > > knnQuery(std::int32_t k, math::Pool pool) const
Returns the k nearest neighbours of every indexed point, self-excluded.
Definition kdtree.h:390
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
void run(const NDArray< T, 2 > &X, std::size_t minSamples, math::Pool pool, MstOutput< T > &out)
Build the MRD-weighted minimum spanning tree of X.
constexpr std::size_t kPrimDenseCoreMinD
constexpr std::size_t kPrimMaxN
Compute budget that gates the streaming Prim backend, expressed as the maximum point count it will ac...
constexpr std::size_t kPrimDenseCoreMinN
Thresholds gating the dense symmetric core-distance pass.
constexpr std::size_t kPrimPersistentRelaxMinWorkers
constexpr std::size_t kPrimMrdMatrixByteBudget
Equivalent byte-budget phrasing of kPrimMaxN, kept so callers that gate on n*n*sizeof(T) <= kPrimMrdM...
constexpr std::size_t kPrimPersistentRelaxMinOpsPerWorker
constexpr std::size_t kPrimDenseCoreMaxMinSamples
One edge of the minimum spanning tree of mutual-reachability distances.
Definition mst_output.h:22
Frozen output contract of every MST backend.
Definition mst_output.h:41
NDArray< T, 1 > coreDistances
Per-point core distance (length N; self-excluded kNN distance at minSamples).
Definition mst_output.h:45
std::vector< MstEdge< T > > edges
The N - 1 MST edges, in insertion order.
Definition mst_output.h:43
Thin compile-time-templated wrapper around the underlying OwnedPool.
Definition thread.h:109
void parallelRunPlex(std::size_t nPhases, std::size_t n, Phase phaseFn)
Run phaseFn for nPhases persistent-worker phases over [0, n).
Definition thread.h:411
std::size_t workerCount() const noexcept
Number of worker threads available, or 1 in serial mode.
Definition thread.h:118
void parallelForBlocks(std::size_t first, std::size_t last, std::size_t numBlocks, Body body)
Run body in parallel over [first, last) partitioned into numBlocks blocks.
Definition thread.h:239
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