Clustering
C++20 header-only: DBSCAN, HDBSCAN, k-means.
Loading...
Searching...
No Matches
kdtree.h
Go to the documentation of this file.
1#pragma once
2
3#include <algorithm>
4#include <cassert>
5#include <cstddef>
6#include <cstdint>
7#include <limits>
8#include <numeric>
9#include <span>
10#include <utility>
11#include <vector>
12
16#include "clustering/math/detail/avx2_helpers.h"
17#include "clustering/math/detail/radius_scan.h"
18#include "clustering/math/detail/sq_distances_block.h"
19#include "clustering/math/detail/top_k_neighbors.h"
22#include "clustering/ndarray.h"
23
24namespace clustering {
25
39struct KDTreeNode {
42 std::size_t m_index;
44 std::size_t m_dim;
53 std::uint32_t m_id;
54};
55
62enum class KDTreeDistanceType : std::uint8_t {
64};
65
91template <class T, KDTreeDistanceType distanceType = KDTreeDistanceType::kEucledian,
92 std::size_t LeafSize = 16, class AllocT = LinearAllocator<KDTreeNode>>
93class KDTree {
94public:
95 using value_type = T;
96
113 KDTree(const NDArray<T, 2> &points, math::Pool pool = {})
114 : m_allocator(calculatePoolSize(points.dim(0))), m_points(points), m_dim(points.dim(1)) {
116 const std::size_t n = points.dim(0);
117 m_indices.resize(n);
118 std::iota(m_indices.begin(), m_indices.end(), 0);
119 // Subtree node counts are a pure function of range size, so every node's arena slot and
120 // id are known before recursion starts; parallel subtree builds write disjoint slots with
121 // no shared allocation state and reproduce the serial layout exactly.
122 const std::size_t totalNodes = nodeCountFor(n);
123 KDTreeNode *arena = (totalNodes > 0) ? m_allocator.allocate(totalNodes) : nullptr;
124 m_root = (totalNodes > 0) ? buildAt(0, n, 0, arena, 0, pool) : nullptr;
125 m_nextNodeId = static_cast<std::uint32_t>(totalNodes);
126 // Materialize points in tree-build order. After @c buildAt rewrites @c m_indices into a
127 // permutation matching the tree layout, a leaf's points live at @c m_points_reordered slots
128 // `[leaf.m_index, leaf.m_index + leaf.m_dim)`. Contiguous access there replaces the
129 // scatter-indirection `m_points[m_indices[k]`] had, which at low d was the cache-miss
130 // ceiling: every leaf-brute-force iteration landed on a random row of @c m_points.
131 // Rows land in disjoint slots, so the copy fans out over the pool.
132 m_points_reordered.resize(n * m_dim);
133 const T *src = points.data();
134 T *dst = m_points_reordered.data();
135 pool.parallelForBlocks(std::size_t{0}, n, std::size_t{0}, [&](std::size_t lo, std::size_t hi) {
136 for (std::size_t k = lo; k < hi; ++k) {
137 const T *s = src + (m_indices[k] * m_dim);
138 T *d = dst + (k * m_dim);
139 for (std::size_t j = 0; j < m_dim; ++j) {
140 d[j] = s[j];
141 }
142 }
143 });
144 // Populate per-node axis-aligned bounding boxes once the tree is built and the reordered
145 // points buffer is materialized. Layout is `(numNodes * 2 * d)` flat: row @c 2*id holds the
146 // min-coords vector, row @c 2*id + 1 holds the max-coords vector. Dual-tree walkers consume
147 // this through @ref nodeBounds as a pair of @c std::span views; leaving @ref KDTreeNode's
148 // size unchanged past the monotonic @c m_id keeps leaf-scan cache behaviour stable at
149 // high @c d.
150 //
151 // Leaf boxes carry the point sweep, so they fan out over the arena; internal boxes are
152 // unions of their children, and the post-order ids let a flat ascending-id pass visit
153 // every child before its parent without recursion.
154 m_nodeBounds.assign(static_cast<std::size_t>(m_nextNodeId) * 2 * m_dim, T{});
155 if (m_root != nullptr) {
156 KDTreeNode *arenaNodes = m_root;
157 pool.parallelForBlocks(std::size_t{0}, totalNodes, std::size_t{0},
158 [&](std::size_t lo, std::size_t hi) {
159 for (std::size_t s = lo; s < hi; ++s) {
160 const KDTreeNode &node = arenaNodes[s];
161 if (node.m_left == nullptr && node.m_right == nullptr) {
162 populateLeafBounds(node);
163 }
164 }
165 });
166 std::vector<const KDTreeNode *> byId(totalNodes, nullptr);
167 for (std::size_t s = 0; s < totalNodes; ++s) {
168 byId[arenaNodes[s].m_id] = &arenaNodes[s];
169 }
170 for (std::size_t id = 0; id < totalNodes; ++id) {
171 const KDTreeNode &node = *byId[id];
172 if (node.m_left != nullptr || node.m_right != nullptr) {
173 unionChildBounds(node);
174 }
175 }
176 }
177 }
178
191 std::vector<std::size_t> query(const NDArray<T, 1> &query_point, T radius,
192 std::int64_t limit = -1) const {
193 std::vector<std::size_t> indices;
194 const T radius_sq = radius * radius;
195 ensureLeafSoa();
196 std::vector<KDTreeNode *> stack;
197 stack.reserve(kDefaultStackReserve);
198 // Copy the borrowed row into a scratch buffer so the core walker can assume a contiguous
199 // `d`-element block regardless of the source layout. The copy is d stores -- free at d=2.
200 std::vector<T> qbuf(m_dim);
201 for (std::size_t k = 0; k < m_dim; ++k) {
202 qbuf[k] = query_point[k];
203 }
204 queryImpl(m_root, qbuf.data(), radius_sq, indices, stack, limit);
205 return indices;
206 }
207
221 [[nodiscard]] index::CoreAdjacency query(T radius, std::size_t minPts, math::Pool pool) const {
222 const std::size_t n = m_points.dim(0);
224 out.rows.resize(n);
225 out.isCore.assign(n, 0);
226 if (n == 0) {
227 return out;
228 }
229
230 const T radius_sq = radius * radius;
231 ensureLeafSoa(pool);
232
233 // Above the dim floor, one box-pruned walk per leaf replaces one walk per point: the
234 // visited-node count collapses by the leaf occupancy while the pair tests grow only by the
235 // box inflation, which the higher per-pair cost at larger d amortizes. Below the floor the
236 // per-pair scan is a few ops and the inflation dominates, so points walk individually.
237 if (m_dim >= kBlockQueryDimFloor && m_root != nullptr) {
238 blockQuery(radius_sq, minPts, pool, out);
239 return out;
240 }
241
242 // Leaves whose bounding-box diagonal fits inside the radius are cliques: every member is
243 // within eps of every other, and with at least minPts members every member is a core. A
244 // query ball that swallows such a leaf can take one representative edge instead of
245 // materializing the members, so flag them once up front.
246 const std::size_t totalNodes = nodeCount();
247 const KDTreeNode *arenaNodes = m_root;
248 std::vector<std::uint8_t> leafAllCore(totalNodes, 0);
250 std::size_t{0}, totalNodes, std::size_t{0}, [&](std::size_t lo, std::size_t hi) {
251 for (std::size_t nodeSlot = lo; nodeSlot < hi; ++nodeSlot) {
252 const KDTreeNode &node = arenaNodes[nodeSlot];
253 if (node.m_left != nullptr || node.m_right != nullptr || node.m_dim < minPts) {
254 continue;
255 }
256 const auto [bmin, bmax] = nodeBounds(&node);
257 T diagSq = T{0};
258 for (std::size_t j = 0; j < m_dim; ++j) {
259 const T ext = bmax[j] - bmin[j];
260 diagSq += ext * ext;
261 }
262 if (diagSq <= radius_sq) {
263 leafAllCore[node.m_id] = 1;
264 }
265 }
266 });
267
268 const std::size_t workers = pool.workerCount();
269 std::vector<std::vector<std::pair<std::int32_t, std::int32_t>>> workerEdges(workers);
270
271 auto runRange = [&](std::size_t lo, std::size_t hi) {
272 // Reuse the traversal stack across every query in this chunk. Tree depth stays below
273 // log2(n / LeafSize) + a few for spilled internal pushes, so kDefaultStackReserve rarely
274 // grows past its initial capacity; clearing keeps the allocation alive between queries.
275 std::vector<KDTreeNode *> stack;
276 stack.reserve(kDefaultStackReserve);
277 const std::size_t adjReserveFloor =
278 std::min(n, (m_dim == kWideAdjReserveDim) ? kWideAdjReserveFloor : kAdjReserveFloor);
279 const T *reordered = m_points_reordered.data();
280 std::vector<std::pair<std::int32_t, std::int32_t>> &edges =
281 workerEdges[math::Pool::workerIndex()];
282 const bool useSoa = !m_leafSoa.empty();
283 for (std::size_t k = lo; k < hi; ++k) {
284 // Walk in tree-build order so consecutive queries share tree paths and keep the visited
285 // nodes warm in cache; m_indices[k] maps the reordered row back to its original slot.
286 const T *qp = reordered + (k * m_dim);
287 const std::size_t rowIdx = m_indices[k];
288 std::vector<std::int32_t> &row = out.rows[rowIdx];
289 // Each row is filled by exactly this query. Seed a reserve floor so the first survivors do
290 // not walk the vector-doubling reallocation cascade from a zero-capacity start.
291 row.reserve(adjReserveFloor);
292
293 // Clique-leaf shortcut: a swallowed all-core leaf contributes its whole population to
294 // the degree and one representative edge to the component build. Taking the shortcut
295 // proves this point core (its final degree can only exceed the running guard), so the
296 // thinned row stays within the core-row contract; a point that never clears the guard
297 // scans normally and keeps a complete row.
298 std::size_t bulkDegree = 0;
299 stack.clear();
300 stack.push_back(m_root);
301 while (!stack.empty()) {
302 KDTreeNode *node = stack.back();
303 stack.pop_back();
304 if (node == nullptr) {
305 continue;
306 }
307 const auto [bmin, bmax] = nodeBounds(node);
308 if (math::pointAabbGapSq(qp, bmin, bmax) > radius_sq) {
309 continue;
310 }
311 if (node->m_left == nullptr && node->m_right == nullptr) {
312 const std::size_t base = node->m_index;
313 const std::size_t count = node->m_dim;
314 if (leafAllCore[node->m_id] != 0 && row.size() + bulkDegree + count >= minPts &&
315 math::pointAabbFarthestSq(qp, bmin, bmax) <= radius_sq) {
316 bulkDegree += count;
317 const auto rep = static_cast<std::int32_t>(m_indices[base]);
318 const auto self = static_cast<std::int32_t>(rowIdx);
319 if (rep != self) {
320 edges.emplace_back(self, rep);
321 }
322 continue;
323 }
324 const T *leafPts = reordered + (base * m_dim);
325 auto emit = [&](std::size_t i) noexcept {
326 row.push_back(static_cast<std::int32_t>(m_indices[base + i]));
327 };
328 if (useSoa) {
329 math::detail::radiusScanSoa(qp, m_leafSoa.data() + (base * m_dim), count, m_dim,
330 radius_sq, emit);
331 } else {
332 math::detail::radiusScan(qp, leafPts, count, m_dim, radius_sq, emit);
333 }
334 continue;
335 }
336 const std::size_t pivotSlot = node->m_index;
337 const T *pivotRow = reordered + (pivotSlot * m_dim);
338 if (math::detail::sqEuclideanRowPtr(qp, pivotRow, m_dim) <= radius_sq) {
339 row.push_back(static_cast<std::int32_t>(m_indices[pivotSlot]));
340 }
341 stack.push_back(node->m_left);
342 stack.push_back(node->m_right);
343 }
344 out.isCore[rowIdx] =
345 (row.size() + bulkDegree >= minPts) ? std::uint8_t{1} : std::uint8_t{0};
346 }
347 };
348
349 if (pool.shouldParallelize(n, 4, 2)) {
350 // Oversubscribe blocks so dynamic stealing balances the skewed per-query degree; one block
351 // per worker lets a dense-neighbourhood region gate the join while the rest idle. Each query
352 // is a full tree walk, so the block floor is finer than the row-light default to keep enough
353 // blocks per worker at small n.
354 pool.parallelForBlocks<citor::HintsDefaults>(
355 std::size_t{0}, n, pool.stealBlocks(n, 64),
356 [&](std::size_t lo, std::size_t hi) { runRange(lo, hi); });
357 } else {
358 runRange(0, n);
359 }
360 for (const auto &edges : workerEdges) {
361 out.extraEdges.insert(out.extraEdges.end(), edges.begin(), edges.end());
362 }
363 return out;
364 }
365
390 [[nodiscard]] std::pair<NDArray<std::int32_t, 2>, NDArray<T, 2>> knnQuery(std::int32_t k,
391 math::Pool pool) const {
392 const std::size_t n = m_points.dim(0);
394 CLUSTERING_ALWAYS_ASSERT(std::cmp_less(k, n));
395
396 const auto kSz = static_cast<std::size_t>(k);
397 NDArray<std::int32_t, 2> indices({n, kSz});
398 NDArray<T, 2> sqDists({n, kSz});
399
400 auto runRange = [&](std::size_t lo, std::size_t hi) {
401 // Reuse the traversal stack and top-k tracker across every query in this chunk; both are
402 // reset at the head of each per-point walk.
403 std::vector<KDTreeNode *> stack;
404 stack.reserve(kDefaultStackReserve);
405 math::detail::TopKNeighbors<T, std::int32_t> topK(kSz);
406 const T *sourceData = m_points.data();
407 std::int32_t *idxOut = indices.data();
408 T *distOut = sqDists.data();
409 for (std::size_t i = lo; i < hi; ++i) {
410 const auto iOriginal = static_cast<std::int32_t>(i);
411 const T *qp = sourceData + (i * m_dim);
412 topK.clear();
413 knnQueryImpl(m_root, qp, iOriginal, topK, stack);
414 topK.drainAscending(distOut + (i * kSz), idxOut + (i * kSz));
415 }
416 };
417
418 if (pool.shouldParallelize(n, 4, 2)) {
419 pool.parallelForBlocks<citor::HintsDefaults>(
420 std::size_t{0}, n, std::size_t{0},
421 [&](std::size_t lo, std::size_t hi) { runRange(lo, hi); });
422 } else {
423 runRange(0, n);
424 }
425 return {std::move(indices), std::move(sqDists)};
426 }
427
440 [[nodiscard]] std::pair<std::span<const T>, std::span<const T>>
441 nodeBounds(const KDTreeNode *node) const noexcept {
442 assert(node != nullptr && "KDTree::nodeBounds on null node");
443 const std::size_t base = static_cast<std::size_t>(node->m_id) * 2 * m_dim;
444 const T *bounds = m_nodeBounds.data() + base;
445 return {std::span<const T>(bounds, m_dim), std::span<const T>(bounds + m_dim, m_dim)};
446 }
447
458 [[nodiscard]] std::span<const std::size_t> indexPermutation() const noexcept {
459 return {m_indices.data(), m_indices.size()};
460 }
461
473 [[nodiscard]] std::span<const T> reorderedPoints() const noexcept {
474 return {m_points_reordered.data(), m_points_reordered.size()};
475 }
476
483 [[nodiscard]] std::size_t nodeCount() const noexcept {
484 return static_cast<std::size_t>(m_nextNodeId);
485 }
486
488 [[nodiscard]] const KDTreeNode *root() const noexcept { return m_root; }
489
491 [[nodiscard]] std::size_t dim() const noexcept { return m_dim; }
492
501 if (m_allocator.isDeallocSupported()) {
502 doRecDealloc(m_root);
503 }
504 }
505
506private:
517 static size_t calculatePoolSize(size_t numPoints) {
518 if (numPoints == 0) {
519 return 0;
520 }
521 // With leaf-size tuning, the number of nodes is at most numPoints
522 // (much less than the original 2*numPoints-1, since leaf nodes batch
523 // up to LeafSize points each).
524 return numPoints;
525 }
526
535 static void doRecDealloc(KDTreeNode *node) {
536 if (node == nullptr) {
537 return;
538 }
539
540 doRecDealloc(node->m_left);
541 doRecDealloc(node->m_right);
542
543 delete node;
544 }
545
549 static constexpr std::size_t kParallelBuildFloor = 512;
550
558 static std::size_t nodeCountFor(std::size_t m) noexcept {
559 if (m == 0) {
560 return 0;
561 }
562 if (m <= LeafSize) {
563 return 1;
564 }
565 const std::size_t mLeft = (m - 1) / 2;
566 return 1 + nodeCountFor(mLeft) + nodeCountFor(m - 1 - mLeft);
567 }
568
589 KDTreeNode *buildAt(std::size_t start, std::size_t end, std::size_t depth, KDTreeNode *arena,
590 std::uint32_t idBase, math::Pool pool) {
591 if (start >= end) {
592 return nullptr;
593 }
594
595 // Leaf node: store range into m_indices, brute-force at query time
596 if (end - start <= LeafSize) {
597 *arena = {.m_index = start, // offset into m_indices
598 .m_dim = end - start, // count of points
599 .m_left = nullptr,
600 .m_right = nullptr,
601 .m_id = idBase};
602 return arena;
603 }
604
605 // Internal node: split on median
606 const std::size_t dim = depth % m_points.dim(1);
607 const std::size_t median = start + (((end - start) - 1) / 2);
608
609 using diff_t = std::vector<std::size_t>::difference_type;
610 std::nth_element(m_indices.begin() + static_cast<diff_t>(start),
611 m_indices.begin() + static_cast<diff_t>(median),
612 m_indices.begin() + static_cast<diff_t>(end),
613 [this, dim](std::size_t lhs, std::size_t rhs) {
614 return m_points[lhs][dim] < m_points[rhs][dim];
615 });
616
617 const std::size_t nLeft = nodeCountFor(median - start);
618 const std::size_t nRight = nodeCountFor(end - median - 1);
619 KDTreeNode *left = nullptr;
620 KDTreeNode *right = nullptr;
621 auto buildLeft = [&] { left = buildAt(start, median, depth + 1, arena + 1, idBase, pool); };
622 auto buildRight = [&] {
623 right = buildAt(median + 1, end, depth + 1, arena + 1 + nLeft,
624 idBase + static_cast<std::uint32_t>(nLeft), pool);
625 };
626 if (end - start > kParallelBuildFloor && pool.pool != nullptr) {
627 pool.forkJoin2(buildLeft, buildRight);
628 } else {
629 buildLeft();
630 buildRight();
631 }
632
633 *arena = {.m_index = median, // reordered slot of the pivot
634 .m_dim = dim,
635 .m_left = left,
636 .m_right = right,
637 .m_id = idBase + static_cast<std::uint32_t>(nLeft + nRight)};
638
639 return arena;
640 }
641
644 static constexpr std::size_t kBlockQueryDimFloor = 4;
645
659 void blockQuery(T radius_sq, std::size_t minPts, math::Pool pool,
660 index::CoreAdjacency &out) const {
661 const std::size_t n = m_points.dim(0);
662 const std::size_t totalNodes = nodeCount();
663 const KDTreeNode *arenaNodes = m_root;
664 std::vector<const KDTreeNode *> leaves;
665 std::vector<const KDTreeNode *> pivots;
666 leaves.reserve(totalNodes);
667 for (std::size_t nodeSlot = 0; nodeSlot < totalNodes; ++nodeSlot) {
668 const KDTreeNode &node = arenaNodes[nodeSlot];
669 if (node.m_left == nullptr && node.m_right == nullptr) {
670 leaves.push_back(&node);
671 } else {
672 pivots.push_back(&node);
673 }
674 }
675
676 const T *reordered = m_points_reordered.data();
677 const std::size_t adjReserveFloor =
678 std::min(n, (m_dim == kWideAdjReserveDim) ? kWideAdjReserveFloor : kAdjReserveFloor);
679 const bool useSoa = !m_leafSoa.empty();
680
681 auto runLeafRange = [&](std::size_t lo, std::size_t hi) {
682 std::vector<const KDTreeNode *> stack;
683 stack.reserve(kDefaultStackReserve);
684 for (std::size_t li = lo; li < hi; ++li) {
685 const KDTreeNode &source = *leaves[li];
686 const std::size_t base = source.m_index;
687 const std::size_t count = source.m_dim;
688 const auto [srcMin, srcMax] = nodeBounds(&source);
689 for (std::size_t i = 0; i < count; ++i) {
690 out.rows[m_indices[base + i]].reserve(adjReserveFloor);
691 }
692
693 stack.clear();
694 stack.push_back(m_root);
695 while (!stack.empty()) {
696 const KDTreeNode *node = stack.back();
697 stack.pop_back();
698 if (node == nullptr) {
699 continue;
700 }
701 const auto [nodeMin, nodeMax] = nodeBounds(node);
702 if (math::aabbAabbGapSq(srcMin, srcMax, nodeMin, nodeMax) > radius_sq) {
703 continue;
704 }
705 if (node->m_left == nullptr && node->m_right == nullptr) {
706 const std::size_t targetBase = node->m_index;
707 const std::size_t targetCount = node->m_dim;
708 const T *targetPts = reordered + (targetBase * m_dim);
709 const T *targetSoa = useSoa ? m_leafSoa.data() + (targetBase * m_dim) : nullptr;
710 std::size_t i = 0;
711 if (useSoa) {
712 // Paired sources share each target-column load and one call setup.
713 for (; i + 2 <= count; i += 2) {
714 std::vector<std::int32_t> &row0 = out.rows[m_indices[base + i]];
715 std::vector<std::int32_t> &row1 = out.rows[m_indices[base + i + 1]];
716 math::detail::radiusScanSoaPair(
717 reordered + ((base + i) * m_dim), reordered + ((base + i + 1) * m_dim),
718 targetSoa, targetCount, m_dim, radius_sq,
719 [&](std::size_t j) noexcept {
720 row0.push_back(static_cast<std::int32_t>(m_indices[targetBase + j]));
721 },
722 [&](std::size_t j) noexcept {
723 row1.push_back(static_cast<std::int32_t>(m_indices[targetBase + j]));
724 });
725 }
726 }
727 for (; i < count; ++i) {
728 const T *qp = reordered + ((base + i) * m_dim);
729 std::vector<std::int32_t> &row = out.rows[m_indices[base + i]];
730 auto emit = [&](std::size_t j) noexcept {
731 row.push_back(static_cast<std::int32_t>(m_indices[targetBase + j]));
732 };
733 if (useSoa) {
734 math::detail::radiusScanSoa(qp, targetSoa, targetCount, m_dim, radius_sq, emit);
735 } else {
736 math::detail::radiusScan(qp, targetPts, targetCount, m_dim, radius_sq, emit);
737 }
738 }
739 continue;
740 }
741 const T *pivotRow = reordered + (node->m_index * m_dim);
742 const auto pivotIdx = static_cast<std::int32_t>(m_indices[node->m_index]);
743 for (std::size_t i = 0; i < count; ++i) {
744 const T *qp = reordered + ((base + i) * m_dim);
745 if (math::detail::sqEuclideanRowPtr(qp, pivotRow, m_dim) <= radius_sq) {
746 out.rows[m_indices[base + i]].push_back(pivotIdx);
747 }
748 }
749 stack.push_back(node->m_left);
750 stack.push_back(node->m_right);
751 }
752
753 for (std::size_t i = 0; i < count; ++i) {
754 const std::size_t rowIdx = m_indices[base + i];
755 out.isCore[rowIdx] =
756 (out.rows[rowIdx].size() >= minPts) ? std::uint8_t{1} : std::uint8_t{0};
757 }
758 }
759 };
760
761 auto runPivotRange = [&](std::size_t lo, std::size_t hi) {
762 std::vector<KDTreeNode *> stack;
763 stack.reserve(kDefaultStackReserve);
764 for (std::size_t pi = lo; pi < hi; ++pi) {
765 const std::size_t slot = pivots[pi]->m_index;
766 const T *qp = reordered + (slot * m_dim);
767 const std::size_t rowIdx = m_indices[slot];
768 std::vector<std::int32_t> &row = out.rows[rowIdx];
769 row.reserve(adjReserveFloor);
770 queryImpl(m_root, qp, radius_sq, row, stack, /*limit=*/-1);
771 out.isCore[rowIdx] = (row.size() >= minPts) ? std::uint8_t{1} : std::uint8_t{0};
772 }
773 };
774
775 if (pool.shouldParallelize(n, 4, 2)) {
776 pool.parallelForBlocks<citor::HintsDefaults>(
777 std::size_t{0}, leaves.size(), pool.stealBlocks(leaves.size(), 1), runLeafRange);
778 pool.parallelForBlocks(std::size_t{0}, pivots.size(), std::size_t{0}, runPivotRange);
779 } else {
780 runLeafRange(0, leaves.size());
781 runPivotRange(0, pivots.size());
782 }
783 }
784
806 template <class OutIdx>
807 void queryImpl(KDTreeNode *root, const T *qp, T radius_sq, std::vector<OutIdx> &indices,
808 std::vector<KDTreeNode *> &stack, std::int64_t limit = -1) const {
809 if (root == nullptr) {
810 return;
811 }
812
813 stack.clear();
814 stack.push_back(root);
815
816 const T *reorderedBase = m_points_reordered.data();
817
818 while (!stack.empty()) {
819 const KDTreeNode *node = stack.back();
820 stack.pop_back();
821
822 if (node == nullptr) {
823 continue;
824 }
825
826 if (limit != -1 && indices.size() == static_cast<std::size_t>(limit)) {
827 break;
828 }
829
830 // Leaf node: brute-force all points in the range. @c m_points_reordered lays points in
831 // tree-build order, so a leaf's entries are a contiguous @c count x d block -- one or
832 // two cache lines at small @c d. @ref math::detail::radiusScan dispatches into a SIMD
833 // kernel when @c d matches a batched width (today @c f32, `d == 2`) and otherwise
834 // falls back to the scalar @ref sqEuclideanRowPtr per-row primitive.
835 if (node->m_left == nullptr && node->m_right == nullptr) {
836 const std::size_t base = node->m_index;
837 const std::size_t count = node->m_dim;
838 const T *leafPts = reorderedBase + (base * m_dim);
839 // The SoA leaf copy exists only when @c d clears @ref kSoaLeafDimFloor; it is built lazily
840 // on the first radius query, and when present scans without a horizontal sum; otherwise the
841 // AoS scan runs.
842 const bool useSoa = !m_leafSoa.empty();
843 const T *leafSoa = useSoa ? m_leafSoa.data() + (base * m_dim) : nullptr;
844 const bool isBounded = (limit != -1);
845 if (!isBounded) {
846 // Unbounded radius query (the DBSCAN adjacency sweep): every survivor is kept, so the
847 // leaf-scan emit skips the per-point capacity test the bounded path needs.
848 auto emit = [&](std::size_t i) noexcept {
849 indices.push_back(static_cast<OutIdx>(m_indices[base + i]));
850 };
851 if (useSoa) {
852 math::detail::radiusScanSoa(qp, leafSoa, count, m_dim, radius_sq, emit);
853 } else {
854 math::detail::radiusScan(qp, leafPts, count, m_dim, radius_sq, emit);
855 }
856 continue;
857 }
858 const auto cap = static_cast<std::size_t>(limit);
859 auto emit = [&](std::size_t i) noexcept {
860 if (indices.size() >= cap) {
861 return;
862 }
863 indices.push_back(static_cast<OutIdx>(m_indices[base + i]));
864 };
865 if (useSoa) {
866 math::detail::radiusScanSoa(qp, leafSoa, count, m_dim, radius_sq, emit);
867 } else {
868 math::detail::radiusScan(qp, leafPts, count, m_dim, radius_sq, emit);
869 }
870 if (indices.size() >= cap) {
871 break;
872 }
873 continue;
874 }
875
876 // Internal node: check the split point and traverse children. `node->m_index` is the
877 // pivot's slot in @c m_points_reordered, so the pivot's row lives at
878 // @c reorderedBase + slot * m_dim. The original point index (needed for @c indices)
879 // comes from `m_indices[slot]`.
880 const std::size_t pivotSlot = node->m_index;
881 const std::size_t splitDim = node->m_dim;
882 const T *pivotRow = reorderedBase + (pivotSlot * m_dim);
883 const T dist_sq = math::detail::sqEuclideanRowPtr(qp, pivotRow, m_dim);
884 if (dist_sq <= radius_sq) {
885 indices.push_back(static_cast<OutIdx>(m_indices[pivotSlot]));
886 }
887
888 const T pivotCoord = pivotRow[splitDim];
889 const T diff = qp[splitDim] - pivotCoord;
890 // Range queries scan the near side unconditionally and the far side only when the split
891 // plane lies within the radius. Discovery order does not matter here -- every matching leaf
892 // is scanned and the adjacency is order independent -- so the symmetric near / far branches
893 // collapse into two straight-line conditional pushes. `diff < 0` selects which child is the
894 // near side; `farWithinRadius` admits the other when the plane is reachable.
895 const bool farWithinRadius = diff * diff <= radius_sq;
896 if (node->m_left != nullptr && (diff < 0 || farWithinRadius)) {
897 stack.push_back(node->m_left);
898 }
899 if (node->m_right != nullptr && (diff >= 0 || farWithinRadius)) {
900 stack.push_back(node->m_right);
901 }
902 }
903 }
904
922 void knnQueryImpl(KDTreeNode *root, const T *qp, std::int32_t selfIndex,
923 math::detail::TopKNeighbors<T, std::int32_t> &topK,
924 std::vector<KDTreeNode *> &stack) const {
925 if (root == nullptr) {
926 return;
927 }
928
929 stack.clear();
930 stack.push_back(root);
931
932 const T *reorderedBase = m_points_reordered.data();
933
934 while (!stack.empty()) {
935 const KDTreeNode *node = stack.back();
936 stack.pop_back();
937
938 if (node == nullptr) {
939 continue;
940 }
941
942 // Current pruning bound. Until the tracker fills, accept everything; once full, the
943 // retained worst-key serves as the upper bound.
944 const T bound = topK.boundKey();
945
946 // AABB gap prune: the minimum possible squared distance from the query to any point
947 // under @c node is @c pointAabbGapSq against the subtree's bounding box. The parent's
948 // single-axis prune is a strictly weaker lower bound; at d>=4 the full-AABB gap skips
949 // subtrees the axis prune lets through, which at d=8 is the dominant share of the kNN
950 // walker's remaining internal-node visits.
951 if (bound != std::numeric_limits<T>::max()) {
952 auto [nmin, nmax] = this->nodeBounds(node);
953 const T gapSq = math::pointAabbGapSq<T>(qp, nmin, nmax);
954 if (gapSq >= bound) {
955 continue;
956 }
957 }
958
959 if (node->m_left == nullptr && node->m_right == nullptr) {
960 // Leaf: walk the contiguous count x d block and admit each candidate. Self-exclusion
961 // is by original index so a candidate is skipped iff it is the query row. Distances
962 // are computed in blocks of four so the horizontal-sum epilogue is shared across
963 // neighbours; the top-k admit then runs over the precomputed dsq vector.
964 const std::size_t base = node->m_index;
965 const std::size_t count = node->m_dim;
966 const T *leafPts = reorderedBase + (base * m_dim);
967 std::array<T, LeafSize> dsqBuf{};
968 math::detail::sqDistancesAosBlock<T>(qp, leafPts, count, m_dim, dsqBuf.data());
969 // Batch-level prune: if every distance in the leaf exceeds the current worst retained
970 // bound, no candidate can enter the tracker. Scan for the minimum once; the common
971 // case at LeafSize=64 with k in [4, 32] is that every distance falls above the bound
972 // and the per-entry admit loop (self-exclude cmp, index load, topK.push) is skipped
973 // entirely. The scan runs scalar because LeafSize is known at compile time and the
974 // auto-vectoriser produces a tight min-reduce with no hsum epilogue of its own.
975 if (topK.full()) {
976 T minDsq = dsqBuf[0];
977 for (std::size_t i = 1; i < count; ++i) {
978 if (dsqBuf[i] < minDsq) {
979 minDsq = dsqBuf[i];
980 }
981 }
982 if (minDsq >= bound) {
983 continue;
984 }
985 }
986 for (std::size_t i = 0; i < count; ++i) {
987 const auto pointIdx = static_cast<std::int32_t>(m_indices[base + i]);
988 if (pointIdx == selfIndex) {
989 continue;
990 }
991 topK.push(dsqBuf[i], pointIdx);
992 }
993 continue;
994 }
995
996 // Internal: test the pivot, then descend near child first so the bound tightens before
997 // the far-child prune test. Compute the split-axis delta squared first so both the pivot
998 // admit and the descend-far gate can share it: the full d-wide pivot distance is at least
999 // the split-axis contribution, so a larger delta squared proves the pivot cannot enter
1000 // the retained set and the d-wide hsum is skipped entirely.
1001 const std::size_t pivotSlot = node->m_index;
1002 const std::size_t splitDim = node->m_dim;
1003 const T *pivotRow = reorderedBase + (pivotSlot * m_dim);
1004 const auto pivotIdx = static_cast<std::int32_t>(m_indices[pivotSlot]);
1005 const T pivotCoord = pivotRow[splitDim];
1006 const T diff = qp[splitDim] - pivotCoord;
1007 const T diffSq = diff * diff;
1008 if (pivotIdx != selfIndex && diffSq <= bound) {
1009 const T dist_sq = math::detail::sqEuclideanRowPtr(qp, pivotRow, m_dim);
1010 topK.push(dist_sq, pivotIdx);
1011 }
1012 // DFS descends near-first, far-second. Stack is LIFO, so push far before near.
1013 if (diff < 0) {
1014 if (diffSq <= bound && node->m_right != nullptr) {
1015 stack.push_back(node->m_right);
1016 }
1017 if (node->m_left != nullptr) {
1018 stack.push_back(node->m_left);
1019 }
1020 } else {
1021 if (diffSq <= bound && node->m_left != nullptr) {
1022 stack.push_back(node->m_left);
1023 }
1024 if (node->m_right != nullptr) {
1025 stack.push_back(node->m_right);
1026 }
1027 }
1028 }
1029 }
1030
1033 void ensureLeafSoa(math::Pool pool = {}) const {
1034 if (m_dim < kSoaLeafDimFloor || m_root == nullptr || !m_leafSoa.empty()) {
1035 return;
1036 }
1037 m_leafSoa.resize(m_points_reordered.size());
1038 // Leaves transpose disjoint slices, so the pass fans out over the contiguous node arena.
1039 const KDTreeNode *arenaNodes = m_root;
1040 pool.parallelForBlocks(std::size_t{0}, nodeCount(), std::size_t{0},
1041 [&](std::size_t lo, std::size_t hi) {
1042 for (std::size_t nodeSlot = lo; nodeSlot < hi; ++nodeSlot) {
1043 const KDTreeNode &node = arenaNodes[nodeSlot];
1044 if (node.m_left == nullptr && node.m_right == nullptr) {
1045 transposeLeafSoa(node);
1046 }
1047 }
1048 });
1049 }
1050
1054 void transposeLeafSoa(const KDTreeNode &leaf) const noexcept {
1055 const std::size_t base = leaf.m_index;
1056 const std::size_t count = leaf.m_dim;
1057 const T *aos = m_points_reordered.data() + (base * m_dim);
1058 T *soa = m_leafSoa.data() + (base * m_dim);
1059 for (std::size_t p = 0; p < count; ++p) {
1060 for (std::size_t f = 0; f < m_dim; ++f) {
1061 soa[(f * count) + p] = aos[(p * m_dim) + f];
1062 }
1063 }
1064 }
1065
1067 void populateLeafBounds(const KDTreeNode &node) noexcept {
1068 T *minOut = m_nodeBounds.data() + (static_cast<std::size_t>(node.m_id) * 2 * m_dim);
1069 T *maxOut = minOut + m_dim;
1070 const std::size_t base = node.m_index;
1071 const std::size_t count = node.m_dim;
1072 const T *leafPts = m_points_reordered.data() + (base * m_dim);
1073 for (std::size_t j = 0; j < m_dim; ++j) {
1074 minOut[j] = leafPts[j];
1075 maxOut[j] = leafPts[j];
1076 }
1077 for (std::size_t i = 1; i < count; ++i) {
1078 const T *row = leafPts + (i * m_dim);
1079 for (std::size_t j = 0; j < m_dim; ++j) {
1080 if (row[j] < minOut[j]) {
1081 minOut[j] = row[j];
1082 }
1083 if (row[j] > maxOut[j]) {
1084 maxOut[j] = row[j];
1085 }
1086 }
1087 }
1088 }
1089
1093 void unionChildBounds(const KDTreeNode &node) noexcept {
1094 T *minOut = m_nodeBounds.data() + (static_cast<std::size_t>(node.m_id) * 2 * m_dim);
1095 T *maxOut = minOut + m_dim;
1096
1097 const KDTreeNode *const seed = (node.m_left != nullptr) ? node.m_left : node.m_right;
1098 const T *seedMin = m_nodeBounds.data() + (static_cast<std::size_t>(seed->m_id) * 2 * m_dim);
1099 const T *seedMax = seedMin + m_dim;
1100 for (std::size_t j = 0; j < m_dim; ++j) {
1101 minOut[j] = seedMin[j];
1102 maxOut[j] = seedMax[j];
1103 }
1104
1105 if (node.m_left != nullptr && node.m_right != nullptr) {
1106 const T *otherMin =
1107 m_nodeBounds.data() + (static_cast<std::size_t>(node.m_right->m_id) * 2 * m_dim);
1108 const T *otherMax = otherMin + m_dim;
1109 for (std::size_t j = 0; j < m_dim; ++j) {
1110 if (otherMin[j] < minOut[j]) {
1111 minOut[j] = otherMin[j];
1112 }
1113 if (otherMax[j] > maxOut[j]) {
1114 maxOut[j] = otherMax[j];
1115 }
1116 }
1117 }
1118
1119 // Union-in the pivot's own row so the internal-node box encloses the pivot point too.
1120 const std::size_t pivotSlot = node.m_index;
1121 const T *pivotRow = m_points_reordered.data() + (pivotSlot * m_dim);
1122 for (std::size_t j = 0; j < m_dim; ++j) {
1123 if (pivotRow[j] < minOut[j]) {
1124 minOut[j] = pivotRow[j];
1125 }
1126 if (pivotRow[j] > maxOut[j]) {
1127 maxOut[j] = pivotRow[j];
1128 }
1129 }
1130 }
1131
1135 static constexpr std::size_t kDefaultStackReserve = 64;
1136
1139 static constexpr std::size_t kAdjReserveFloor = 8;
1140 static constexpr std::size_t kWideAdjReserveDim = 8;
1141 static constexpr std::size_t kWideAdjReserveFloor = 128;
1142
1146 static constexpr std::size_t kSoaLeafDimFloor = 2;
1147
1148 AllocT m_allocator;
1149
1150 KDTreeNode *m_root = nullptr;
1151 const NDArray<T, 2> &m_points;
1152 std::size_t m_dim = 0;
1153 std::vector<std::size_t> m_indices;
1155 std::vector<T> m_points_reordered;
1161 mutable std::vector<T> m_leafSoa;
1164 std::uint32_t m_nextNodeId = 0;
1168 std::vector<T> m_nodeBounds;
1169};
1170
1171} // namespace clustering
#define CLUSTERING_ALWAYS_ASSERT(cond)
Release-active assertion: evaluates cond in every build configuration.
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
std::vector< std::size_t > query(const NDArray< T, 1 > &query_point, T radius, std::int64_t limit=-1) const
Finds points within a specified radius of a query point.
Definition kdtree.h:191
KDTree(const NDArray< T, 2 > &points, math::Pool pool={})
Constructs a KDTree using a given set of points.
Definition kdtree.h:113
std::span< const std::size_t > indexPermutation() const noexcept
Permutation from reordered-slot index to original point index.
Definition kdtree.h:458
~KDTree()
Destroys the KDTree, deallocating its nodes.
Definition kdtree.h:500
std::pair< std::span< const T >, std::span< const T > > nodeBounds(const KDTreeNode *node) const noexcept
Axis-aligned bounding box of the points routed through node.
Definition kdtree.h:441
index::CoreAdjacency query(T radius, std::size_t minPts, math::Pool pool) const
Returns the full radius-neighborhood adjacency over the indexed point cloud.
Definition kdtree.h:221
std::size_t nodeCount() const noexcept
Total node count, equal to one past the largest m_id assigned during construction.
Definition kdtree.h:483
std::size_t dim() const noexcept
Dimension count of the indexed point cloud.
Definition kdtree.h:491
std::span< const T > reorderedPoints() const noexcept
Points in reordered (tree-build) order as a flat row-major buffer.
Definition kdtree.h:473
const KDTreeNode * root() const noexcept
Root of the tree; nullptr for an empty point set.
Definition kdtree.h:488
Represents a multidimensional array (NDArray) of a fixed number of dimensions N and element type T.
Definition ndarray.h:136
bool isContiguous() const noexcept
Reports whether the array's runtime layout is row-major contiguous with zero offset.
Definition ndarray.h:475
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
T pointAabbGapSq(const T *point, std::span< const T > boxMin, std::span< const T > boxMax) noexcept
Squared gap distance between a point and an axis-aligned bounding box.
Definition aabb.h:44
T aabbAabbGapSq(std::span< const T > minA, std::span< const T > maxA, std::span< const T > minB, std::span< const T > maxB) noexcept
Squared gap distance between two axis-aligned bounding boxes.
Definition aabb.h:83
T pointAabbFarthestSq(const T *point, std::span< const T > boxMin, std::span< const T > boxMax) noexcept
Squared farthest distance from a point to any point of an axis-aligned bounding box.
Definition aabb.h:114
KDTreeDistanceType
Distance metric a KDTree builds pruning bounds under.
Definition kdtree.h:62
@ kEucledian
Squared Euclidean; radii and comparisons run on squared distances.
Definition kdtree.h:63
Node in a KDTree, sharing the same struct shape between internals and leaves.
Definition kdtree.h:39
std::size_t m_dim
Internal: split dimension. Leaf: point count packed at m_index.
Definition kdtree.h:44
KDTreeNode * m_right
Right child, or nullptr on a leaf.
Definition kdtree.h:48
std::size_t m_index
Internal: pivot slot in the tree's reordered point buffer.
Definition kdtree.h:42
KDTreeNode * m_left
Left child, or nullptr on a leaf.
Definition kdtree.h:46
std::uint32_t m_id
Monotonic identifier assigned at construction; keys into the owning tree's per-node bounds buffer.
Definition kdtree.h:53
Radius-neighborhood adjacency with per-point core flags.
Definition range_query.h:30
std::vector< std::vector< std::int32_t > > rows
Per-point neighbour lists.
Definition range_query.h:31
std::vector< std::uint8_t > isCore
Per-point core flag from the full degree.
Definition range_query.h:32
std::vector< std::pair< std::int32_t, std::int32_t > > extraEdges
Core-core edges carried outside rows.
Definition range_query.h:37
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
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
std::size_t stealBlocks(std::size_t n, std::size_t minRowsPerBlock=256) const noexcept
Block count for a fan-out over n rows that lets work-stealing balance heterogeneous cores.
Definition thread.h:191
bool shouldParallelize(std::size_t totalWork, std::size_t minChunk, std::size_t minTasksPerWorker=2) const noexcept
Decide whether totalWork warrants parallel dispatch.
Definition thread.h:147