# SPDX-FileCopyrightText: Copyright (c) 2023 - 2026 NVIDIA CORPORATION & AFFILIATES.
# SPDX-FileCopyrightText: All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Spatial cluster tree for dual-tree Barnes-Hut acceleration.
This module provides a GPU-compatible hierarchical spatial decomposition over a
set of points, designed for dual-tree Barnes-Hut O(N) acceleration of
kernel-summation and attention-style operators (e.g. GLOBE's field kernels and
mesh attention layers).
Trees are built over both source and target points. The dual-tree traversal
classifies (target_node, source_node) pairs as near-field or far-field:
- **Near-field**: both nodes are leaves and nearby - expand to individual
(target, source) pairs for exact kernel evaluation.
- **Far-field**: nodes are well-separated - evaluate the kernel ONCE at the
node centroids and broadcast the result to all targets in the target node.
This reduces far-field kernel evaluations from O(N log N) (single-tree) to
O(N) (dual-tree), which is critical at large mesh scales (800k+ faces).
Construction uses the same morton-code-based Linear BVH (LBVH) algorithm as
:mod:`physicsnemo.mesh.spatial.bvh` (morton sort, midpoint splits, bottom-up
AABB propagation), but the resulting data structure differs: ClusterTree stores
additional per-node fields (diameter, subtree ranges, area-weighted aggregates)
needed for the Barnes-Hut opening criterion, dual-tree traversal, and
far-field monopole approximation. The two classes share
:func:`~physicsnemo.mesh.spatial.bvh._compute_morton_codes` and
:func:`~physicsnemo.mesh.spatial._ragged._ragged_arange` but are otherwise
independent.
"""
import logging
from typing import NamedTuple
import torch
import torch.nn.functional as F
from jaxtyping import Float, Int
from tensordict import TensorDict, tensorclass
from torch.profiler import record_function
from physicsnemo.mesh.spatial._lbvh import build_lbvh_topology
from physicsnemo.mesh.spatial._ragged import _ragged_arange
from physicsnemo.mesh.spatial.bvh import _compute_morton_codes
logger = logging.getLogger("mesh.spatial.cluster_tree")
# ---------------------------------------------------------------------------
# InteractionPlan: the output of tree traversal
# ---------------------------------------------------------------------------
[docs]
@tensorclass
class DualInteractionPlan:
r"""Result of a dual-tree Barnes-Hut traversal: four categories of
interactions that together cover all source contributions for every
target point.
**(near, near)**: ``(near_target_ids[i], near_source_ids[i])`` are
individual target-source pairs requiring exact kernel evaluation.
**(far, far)**: ``(far_target_node_ids[i], far_source_node_ids[i])``
are node-to-node pairs where the kernel is evaluated ONCE at the
node centroids and the result is broadcast to all individual targets
in the target node.
**(near, far)**: ``(nf_target_ids[i], nf_source_node_ids[i])`` are
individual target points paired with source nodes. The kernel is
evaluated at ``(target_point, source_centroid)`` using the source
node's monopole approximation. No target-side broadcast.
**(far, near)**: ``(fn_target_node_ids[i], fn_source_ids[i])`` are
target nodes paired with individual source points. The kernel is
evaluated at ``(target_centroid, source_point)`` using exact source
data, then broadcast to stage-1 survivor targets via the
``fn_broadcast_*`` mapping.
All index tensors are ``int64`` on the same device as the tree.
"""
near_target_ids: Int[torch.Tensor, " n_near"]
near_source_ids: Int[torch.Tensor, " n_near"]
far_target_node_ids: Int[torch.Tensor, " n_far_nodes"]
far_source_node_ids: Int[torch.Tensor, " n_far_nodes"]
nf_target_ids: Int[torch.Tensor, " n_nf"]
nf_source_node_ids: Int[torch.Tensor, " n_nf"]
fn_target_node_ids: Int[torch.Tensor, " n_fn"]
fn_source_ids: Int[torch.Tensor, " n_fn"]
fn_broadcast_targets: Int[torch.Tensor, " n_fn_bcast"]
fn_broadcast_starts: Int[torch.Tensor, " n_fn"]
fn_broadcast_counts: Int[torch.Tensor, " n_fn"]
@property
def n_near(self) -> int:
"""Number of (near,near) exact individual interaction pairs."""
return self.near_target_ids.shape[0]
@property
def n_far_nodes(self) -> int:
"""Number of (far,far) node-to-node pairs (each = one kernel eval)."""
return self.far_target_node_ids.shape[0]
@property
def n_nf(self) -> int:
"""Number of (near,far) target-point-to-source-node pairs."""
return self.nf_target_ids.shape[0]
@property
def n_fn(self) -> int:
"""Number of (far,near) target-node-to-source-point pairs."""
return self.fn_target_node_ids.shape[0]
[docs]
def validate(self) -> None:
"""Check internal consistency of the interaction plan.
Verifies shape pairing, non-negativity, and fn_broadcast bounds.
Raises ``ValueError`` on any inconsistency. Intended to be called
behind a ``not torch.compiler.is_compiling()`` guard so it is
zero-cost under ``torch.compile``.
Raises
------
ValueError
If any internal consistency check fails.
"""
### Shape pairing: matched tensor pairs must have identical lengths
pairs: list[tuple[str, torch.Tensor, str, torch.Tensor]] = [
(
"near_target_ids",
self.near_target_ids,
"near_source_ids",
self.near_source_ids,
),
(
"far_target_node_ids",
self.far_target_node_ids,
"far_source_node_ids",
self.far_source_node_ids,
),
(
"nf_target_ids",
self.nf_target_ids,
"nf_source_node_ids",
self.nf_source_node_ids,
),
(
"fn_target_node_ids",
self.fn_target_node_ids,
"fn_source_ids",
self.fn_source_ids,
),
]
for name_a, a, name_b, b in pairs:
if a.shape != b.shape:
raise ValueError(
f"Shape mismatch: {name_a}.shape={a.shape!r} != "
f"{name_b}.shape={b.shape!r}"
)
### fn_broadcast tensors must be consistently sized AND non-negative.
n_fn = self.fn_source_ids.shape[0]
for name, tensor in [
("fn_broadcast_starts", self.fn_broadcast_starts),
("fn_broadcast_counts", self.fn_broadcast_counts),
]:
if tensor.shape != (n_fn,):
raise ValueError(f"{name}.shape={tensor.shape!r}, expected ({n_fn},)")
if tensor.numel() > 0 and (tensor < 0).any():
raise ValueError(f"{name} contains negative values")
### fn_broadcast bounds: every (start, count) range with count > 0
### must fit within fn_broadcast_targets. Zero-count entries are
### no-ops whose starts are never dereferenced.
if n_fn > 0:
nonzero = self.fn_broadcast_counts > 0
if nonzero.any():
ends = (
self.fn_broadcast_starts[nonzero]
+ self.fn_broadcast_counts[nonzero]
)
max_end = ends.max().item()
bcast_len = self.fn_broadcast_targets.shape[0]
if max_end > bcast_len:
raise ValueError(
f"fn_broadcast out of bounds: max(starts + counts)="
f"{max_end} > fn_broadcast_targets.shape[0]={bcast_len}"
)
class _ExpandedLeafHits(NamedTuple):
"""Per-iteration output of :func:`_expand_dual_leaf_hits`.
Three of the four interaction streams are returned in
*deferred-compaction* form: the per-element tensor is unfiltered
(length ``t_full`` or ``s_full``) and accompanied by a boolean
validity mask, so the caller can amortise compaction across all
traversal iterations into a single boolean indexing per stream.
Fields
------
near_tgts, near_srcs : Int[Tensor, " n_near"]
(near, near) Cartesian-product pairs. Already compacted -
``_ragged_arange`` sized the output by data anyway.
nf_tgts, nf_snids : Int[Tensor, " t_full"]
(near, far) target IDs / source-node IDs, length :math:`T_\\text{full}
= \\sum t_\\text{counts}`.
nf_validity : Bool[Tensor, " t_full"]
Mask selecting the (near, far) entries. Equals ``target_is_far``.
fn_sids, fn_tnids : Int[Tensor, " s_full"]
(far, near) source IDs / target-node IDs, length :math:`S_\\text{full}
= \\sum s_\\text{counts}`.
fn_validity : Bool[Tensor, " s_full"]
Mask selecting the (far, near) entries. Equals ``source_is_far``.
fn_bcast_starts, fn_bcast_counts : Int[Tensor, " s_full"]
Per-source start/count into ``fn_bcast_targets``. Aligned with
the fn stream; filter by ``fn_validity``. Non-fn entries have
arithmetically defined but unused values.
fn_bcast_targets : Int[Tensor, " t_full"]
Active survivor target IDs sorted by leaf pair, *sentinel-padded*
at the tail. Compact via ``fn_bcast_targets_validity``.
fn_bcast_targets_validity : Bool[Tensor, " t_full"]
Mask selecting active (non-sentinel) entries.
"""
near_tgts: torch.Tensor
near_srcs: torch.Tensor
nf_tgts: torch.Tensor
nf_snids: torch.Tensor
nf_validity: torch.Tensor
fn_sids: torch.Tensor
fn_tnids: torch.Tensor
fn_validity: torch.Tensor
fn_bcast_starts: torch.Tensor
fn_bcast_counts: torch.Tensor
fn_bcast_targets: torch.Tensor
fn_bcast_targets_validity: torch.Tensor
@classmethod
def empty(cls, device: torch.device) -> "_ExpandedLeafHits":
"""All-empty hits, used as the ``n_pairs == 0`` short-circuit."""
el = torch.empty(0, dtype=torch.long, device=device)
eb = torch.empty(0, dtype=torch.bool, device=device)
return cls(
near_tgts=el,
near_srcs=el.clone(),
nf_tgts=el.clone(),
nf_snids=el.clone(),
nf_validity=eb,
fn_sids=el.clone(),
fn_tnids=el.clone(),
fn_validity=eb.clone(),
fn_bcast_starts=el.clone(),
fn_bcast_counts=el.clone(),
fn_bcast_targets=el.clone(),
fn_bcast_targets_validity=eb.clone(),
)
def _expand_dual_leaf_hits(
target_leaf_ids: Int[torch.Tensor, " n_leaf_pairs"],
source_leaf_ids: Int[torch.Tensor, " n_leaf_pairs"],
target_tree: "ClusterTree",
source_tree: "ClusterTree",
theta: float,
) -> _ExpandedLeafHits:
"""Expand ``(target_leaf, source_leaf)`` pairs with two-stage filtering.
Applies two sequential per-point tests to classify each (target, source)
interaction within a leaf pair:
**Stage 1 (per-target)**: Test each target against the source leaf AABB.
Targets that pass become **(near, far)** - they use the source monopole.
Targets that fail are "survivors" and proceed to stage 2.
**Stage 2 (per-source)**: Test each source against the target leaf AABB.
Sources that pass become **(far, near)** - evaluated at the target
centroid and broadcast to all survivors. Sources that fail produce
**(near, near)** Cartesian product pairs with the survivors.
The two stages are independent (different AABBs) and sequential (stage 2
only applies to survivors), so no (target, source) pair is double-counted.
Three of the four output streams are returned in **deferred-compaction**
form on the result struct: the per-element tensor is unfiltered (length
``t_full`` or ``s_full``) and accompanied by a boolean validity mask.
The caller accumulates these across traversal iterations and does ONE
boolean compaction at the end - mirroring the pattern already used for
the far-field stream in ``find_dual_interaction_pairs``. This
eliminates the five per-iter ``aten::nonzero`` syncs that the previous
eagerly-filtered version paid (one each for ``target_is_far``,
``~target_is_far``, ``source_is_far``, ``fn_active_mask``, and
``~source_is_far``).
Returns
-------
_ExpandedLeafHits
See :class:`_ExpandedLeafHits` for the per-field shapes and
deferred-compaction protocol.
"""
device = target_leaf_ids.device
theta_sq = theta * theta
n_pairs = target_leaf_ids.shape[0]
### The early-return guard is a Python ``int`` comparison on a shape
### attribute - zero CUDA cost. It matters because in the early
### traversal iterations (top-of-tree) there are typically no
### leaf-leaf pairs yet, and without this guard the three
### ``_ragged_arange`` calls below would each pay a
### ``torch.arange(scalar_tensor)`` host sync to size their empty
### output. Saves ~3 syncs * (number of leaf-leaf-free early iters)
### per traversal.
if n_pairs == 0:
return _ExpandedLeafHits.empty(device)
### The rest of this function is intentionally written without
### ``if X.any():`` / ``int(X.sum())`` early-exit branches AND without
### ``X.nonzero()`` compactions. Each such call was a CPU-GPU sync
### point in the dual-traversal hot loop; the sync count was the
### dominant CPU stall in profiling. All downstream operations
### (``_ragged_arange``, ``argsort``, ``scatter_add_``, ``scatter_``)
### handle zero-element inputs correctly, so we let empty intermediate
### tensors flow through unconditionally.
t_starts = target_tree.leaf_start[target_leaf_ids]
t_counts = target_tree.leaf_count[target_leaf_ids]
s_starts = source_tree.leaf_start[source_leaf_ids]
s_counts = source_tree.leaf_count[source_leaf_ids]
# ==================================================================
# Stage 1: per-target test against source leaf AABBs
# ==================================================================
positions_t, leaf_pair_ids_t = _ragged_arange(t_starts, t_counts)
target_point_ids = target_tree.sorted_source_order[positions_t]
target_pts = target_tree.source_points[target_point_ids]
src_leaf_per_target = source_leaf_ids[leaf_pair_ids_t]
clamped_t = torch.clamp(
target_pts,
min=source_tree.node_aabb_min[src_leaf_per_target],
max=source_tree.node_aabb_max[src_leaf_per_target],
)
dist_sq_t = (target_pts - clamped_t).pow(2).sum(dim=-1)
target_is_far = (
dist_sq_t * theta_sq > source_tree.node_diameter_sq[src_leaf_per_target]
)
### (near, far) stream is returned unfiltered. ``target_point_ids``,
### ``src_leaf_per_target`` are length ``t_full``; the caller compacts
### them with ``target_is_far`` (== ``nf_validity``) at end-of-traversal.
# ==================================================================
# Stage 2: per-source test against target leaf AABBs
# ==================================================================
positions_s, leaf_pair_ids_s = _ragged_arange(s_starts, s_counts)
src_point_ids = source_tree.sorted_source_order[positions_s]
src_pts = source_tree.source_points[src_point_ids]
tgt_leaf_per_src = target_leaf_ids[leaf_pair_ids_s]
clamped_s = torch.clamp(
src_pts,
min=target_tree.node_aabb_min[tgt_leaf_per_src],
max=target_tree.node_aabb_max[tgt_leaf_per_src],
)
dist_sq_s = (src_pts - clamped_s).pow(2).sum(dim=-1)
source_is_far = (
dist_sq_s * theta_sq > target_tree.node_diameter_sq[tgt_leaf_per_src]
)
### (far, near) stream is returned unfiltered: ``src_point_ids``,
### ``tgt_leaf_per_src`` are length ``s_full``; the caller compacts
### with ``source_is_far`` (== ``fn_validity``).
# ==================================================================
# Build (far, near) broadcast mapping (sync-free, sentinel-padded)
# ==================================================================
# ``has_fn_source[lp]`` is True iff leaf pair ``lp`` has at least one
# fn source (i.e., a source that passed the per-source far test).
# Sync-free construction: scatter ``True`` into ``has_fn_source[lp]``
# for every fn entry and into a sentinel slot for every non-fn entry.
# The original ``has_fn_source[fn_lp_ids] = True`` required a
# ``nonzero`` on ``source_is_far`` to compute the filtered
# ``fn_lp_ids``; ``torch.where`` + sentinel-slot scatter is
# data-flow-equivalent and pays zero CUDA syncs.
has_fn_source_buf = torch.zeros(n_pairs + 1, dtype=torch.bool, device=device)
safe_fn_lp = torch.where(
source_is_far,
leaf_pair_ids_s,
torch.full_like(leaf_pair_ids_s, n_pairs),
)
has_fn_source_buf.scatter_(0, safe_fn_lp, True)
has_fn_source = has_fn_source_buf[:n_pairs]
### An "active" target is a stage-1 survivor whose leaf pair has at
### least one fn source. Working on the unfiltered ``leaf_pair_ids_t``
### lets us build the validity mask without a ``nonzero`` over
### ``~target_is_far``.
active_validity = (~target_is_far) & has_fn_source[leaf_pair_ids_t]
### Sort by ``(leaf_pair_id if active else n_pairs)`` so that within
### the sorted target_point_ids the active entries come first, grouped
### by leaf-pair, followed by all the inactive entries (which carry the
### sentinel key ``n_pairs``). The caller drops the inactive tail via
### ``fn_broadcast_targets_validity`` at end-of-traversal.
bcast_sort_key = torch.where(
active_validity,
leaf_pair_ids_t,
torch.full_like(leaf_pair_ids_t, n_pairs),
)
bcast_sort_order = bcast_sort_key.argsort(stable=True)
fn_broadcast_targets = target_point_ids[bcast_sort_order]
fn_broadcast_targets_validity = active_validity[bcast_sort_order]
### Per-lp active count via weighted ``scatter_add_``. Weight =
### ``active_validity.long()``, so non-active entries contribute zero.
active_counts_per_lp = torch.zeros(n_pairs, dtype=torch.long, device=device)
active_counts_per_lp.scatter_add_(0, leaf_pair_ids_t, active_validity.long())
active_starts_per_lp = active_counts_per_lp.cumsum(0) - active_counts_per_lp
### Return broadcast_starts/counts aligned with the *full* per-source
### axis (length ``s_full``). The caller filters by ``source_is_far``.
fn_broadcast_starts_full = active_starts_per_lp[leaf_pair_ids_s]
fn_broadcast_counts_full = active_counts_per_lp[leaf_pair_ids_s]
# ==================================================================
# Reduced Cartesian product: survivors × close sources only
# ==================================================================
### Per-lp count of close sources via weighted ``scatter_add_``.
close_counts_per_lp = torch.zeros(n_pairs, dtype=torch.long, device=device)
close_counts_per_lp.scatter_add_(0, leaf_pair_ids_s, (~source_is_far).long())
### Sort sources by ``(leaf_pair_id, source_is_far)`` so within each
### lp's contiguous block the close sources come first (key
### ``2*lp + 0``) followed by the far sources (key ``2*lp + 1``).
### This avoids the per-iter ``(~source_is_far).nonzero()`` sync that
### the previous filtered-then-sort version paid. Stable argsort
### preserves the original within-lp order of close sources, matching
### the previous implementation's output element-for-element.
src_sort_key = leaf_pair_ids_s * 2 + source_is_far.long()
src_sort_order = src_sort_key.argsort(stable=True)
sorted_src_ids = src_point_ids[src_sort_order]
### Start of lp's block in ``sorted_src_ids`` is the exclusive cumsum
### of ``s_counts`` (the per-lp total source count, by construction).
total_lp_starts = s_counts.cumsum(0) - s_counts
### Per-target close counts: gate by ``(~target_is_far).long()`` so
### non-survivors get count 0 and produce no Cartesian-product output.
### The block start does not depend on survivor-ness.
per_target_close_counts = (
close_counts_per_lp[leaf_pair_ids_t] * (~target_is_far).long()
)
per_target_close_starts = total_lp_starts[leaf_pair_ids_t]
### Reuse ``_ragged_arange``'s second output (``seg_ids_nn``) as the
### per-element survivor index instead of calling
### ``torch.repeat_interleave(surv_point_ids, per_target_close_counts)``.
### Both ops sync once to size their output; folding them into one
### ``_ragged_arange`` halves that cost. Functionally identical:
### ``repeat_interleave(x, counts)[k] == x[seg_ids[k]]`` by
### definition of segment ids.
src_positions_nn, seg_ids_nn = _ragged_arange(
per_target_close_starts, per_target_close_counts
)
expanded_near_tgts = target_point_ids[seg_ids_nn]
expanded_near_srcs = sorted_src_ids[src_positions_nn]
return _ExpandedLeafHits(
near_tgts=expanded_near_tgts,
near_srcs=expanded_near_srcs,
nf_tgts=target_point_ids,
nf_snids=src_leaf_per_target,
nf_validity=target_is_far,
fn_sids=src_point_ids,
fn_tnids=tgt_leaf_per_src,
fn_validity=source_is_far,
fn_bcast_starts=fn_broadcast_starts_full,
fn_bcast_counts=fn_broadcast_counts_full,
fn_bcast_targets=fn_broadcast_targets,
fn_bcast_targets_validity=fn_broadcast_targets_validity,
)
# ---------------------------------------------------------------------------
# Deferred-compaction helpers (used by find_dual_interaction_pairs)
# ---------------------------------------------------------------------------
def _compact_deferred(
*tensor_lists: list[torch.Tensor],
validity_list: list[torch.Tensor],
device: torch.device,
) -> tuple[torch.Tensor, ...]:
"""Concatenate per-iter accumulators and boolean-index by validity.
Each ``tensor_lists[i]`` is the per-iter accumulator for one output
stream; ``validity_list`` is the shared per-iter validity mask. All
accumulators must be the same length within each iteration. Pays
exactly one ``aten::nonzero`` sync regardless of the number of
output streams - the sync is amortised across them by computing
the integer ``keep_idx`` once and reusing it for every stream.
The empty-``validity_list`` case (no iteration ever contributed to
this stream) is handled explicitly because ``torch.cat([])`` raises.
"""
if not validity_list:
empty = torch.empty(0, dtype=torch.long, device=device)
return tuple(empty.clone() for _ in tensor_lists)
keep_idx = torch.cat(validity_list).nonzero(as_tuple=True)[0]
return tuple(torch.cat(L)[keep_idx] for L in tensor_lists)
def _compact_sentinel_padded(
padded_tensor: torch.Tensor,
referencing_indices: torch.Tensor,
validity: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Compact a sentinel-padded buffer and remap referencing indices.
``padded_tensor`` has don't-care entries wherever ``~validity``;
``referencing_indices`` are integer indices into ``padded_tensor``
that only ever reference *valid* positions. This returns
``(padded_tensor[validity], remapped_indices)`` where the remapped
indices point at the same elements in the compacted buffer.
The remap is the exclusive cumsum of ``validity``: position ``p`` in
the padded buffer maps to ``sum(validity[:p])`` in the compacted
buffer, which is its slot in ``padded_tensor[validity]``. Pays one
sync (the boolean indexing); the cumsum and integer-indexing remap
are sync-free.
"""
valid_long = validity.long()
new_pos = valid_long.cumsum(0) - valid_long
return padded_tensor[validity], new_pos[referencing_indices]
def _sort_by_key(
*tensors: torch.Tensor,
key: torch.Tensor,
) -> tuple[torch.Tensor, ...]:
"""Stable-sort companion tensors by ``key``; no-op on empty input.
Used at the end of ``find_dual_interaction_pairs`` to group each
output stream by source index (or source node) for coalesced
downstream gathers.
"""
if key.numel() == 0:
return tensors
order = key.argsort(stable=True)
return tuple(t[order] for t in tensors)
# ---------------------------------------------------------------------------
# ClusterTree tensorclass
# ---------------------------------------------------------------------------
[docs]
@tensorclass
class ClusterTree:
r"""Hierarchical spatial decomposition for Barnes-Hut kernel acceleration.
Stores a binary radix tree over source points as flat GPU-compatible tensors.
The tree structure (positions, AABBs, children) is precomputable per mesh
geometry. Per-node source-data aggregates are recomputed whenever the source
features change (e.g., between communication hyperlayers).
The tree supports both boundary face centroids and prediction point clouds
(same construction algorithm, same data structure).
Attributes
----------
node_aabb_min : torch.Tensor
AABB minimum corner per node, shape ``(n_nodes, D)``.
node_aabb_max : torch.Tensor
AABB maximum corner per node, shape ``(n_nodes, D)``.
node_diameter_sq : torch.Tensor
Squared AABB diagonal per node, shape ``(n_nodes,)``.
node_left_child : torch.Tensor
Left child index per node, ``-1`` for leaves, shape ``(n_nodes,)``.
node_right_child : torch.Tensor
Right child index per node, ``-1`` for leaves, shape ``(n_nodes,)``.
leaf_start : torch.Tensor
Start offset into ``sorted_source_order`` for leaf nodes,
``-1`` for internal nodes, shape ``(n_nodes,)``.
leaf_count : torch.Tensor
Number of sources in each leaf node, ``0`` for internal nodes,
shape ``(n_nodes,)``.
node_range_start : torch.Tensor
Start offset into ``sorted_source_order`` for ALL nodes (both
leaf and internal), shape ``(n_nodes,)``. Each node's subtree
covers a contiguous range in morton-sorted order.
node_range_count : torch.Tensor
Number of points in each node's subtree, shape ``(n_nodes,)``.
For leaves this equals ``leaf_count``; for internal nodes it
equals the sum of children's range counts.
node_total_area : torch.Tensor
Total source area in each node's subtree, shape ``(n_nodes,)``.
sorted_source_order : torch.Tensor
Morton-code-sorted permutation of source indices,
shape ``(n_sources,)``.
source_points : torch.Tensor
Original source point coordinates, shape ``(n_sources, D)``.
max_depth : torch.Tensor
Scalar tensor storing the tree depth (for fixed-iteration traversal).
"""
node_aabb_min: torch.Tensor
node_aabb_max: torch.Tensor
node_diameter_sq: torch.Tensor
node_left_child: torch.Tensor
node_right_child: torch.Tensor
leaf_start: torch.Tensor
leaf_count: torch.Tensor
node_range_start: torch.Tensor
node_range_count: torch.Tensor
node_total_area: torch.Tensor
sorted_source_order: torch.Tensor
source_points: torch.Tensor
max_depth: torch.Tensor
@property
def n_nodes(self) -> int:
"""Number of nodes in the tree."""
return self.node_aabb_min.shape[0]
@property
def n_sources(self) -> int:
"""Number of source points."""
return self.sorted_source_order.shape[0]
@property
def n_spatial_dims(self) -> int:
"""Spatial dimensionality."""
return self.node_aabb_min.shape[1]
[docs]
@classmethod
def from_points(
cls,
points: Float[torch.Tensor, "n_points n_dims"],
*,
leaf_size: int = 1,
areas: Float[torch.Tensor, " n_points"] | None = None,
) -> "ClusterTree":
r"""Build a cluster tree from a set of points via morton-code LBVH.
Parameters
----------
points : Float[torch.Tensor, "n_points n_dims"]
Source point coordinates, shape :math:`(N, D)`.
leaf_size : int
Maximum sources per leaf node. Larger values produce shallower
trees (fewer traversal iterations) at the cost of more exact
near-field interactions per leaf hit.
areas : Float[torch.Tensor, "n_points"] or None
Per-source area weights used for aggregate computation. If
``None``, all areas default to 1.
Returns
-------
ClusterTree
Constructed tree ready for traversal and aggregate computation.
"""
if leaf_size < 1:
raise ValueError(f"leaf_size must be >= 1, got {leaf_size=!r}")
n_points = points.shape[0]
D = points.shape[1]
device = points.device
dtype = points.dtype
if areas is None:
areas = torch.ones(n_points, device=device, dtype=dtype)
### Handle empty point set
if n_points == 0:
empty_long = torch.empty(0, dtype=torch.long, device=device)
return cls(
node_aabb_min=torch.empty((0, D), dtype=dtype, device=device),
node_aabb_max=torch.empty((0, D), dtype=dtype, device=device),
node_diameter_sq=torch.empty(0, dtype=dtype, device=device),
node_left_child=empty_long,
node_right_child=empty_long,
leaf_start=empty_long,
leaf_count=empty_long,
node_range_start=empty_long,
node_range_count=empty_long,
node_total_area=torch.empty(0, dtype=dtype, device=device),
sorted_source_order=empty_long,
source_points=points,
max_depth=torch.tensor(0, dtype=torch.long, device=device),
batch_size=torch.Size([]),
)
### Sort points by morton code for spatial coherence
with record_function("cluster_tree::morton_sort"):
morton_codes = _compute_morton_codes(points)
sorted_order = morton_codes.argsort(stable=True) # (n_points,)
sorted_points = points[sorted_order] # (n_points, D)
sorted_areas = areas[sorted_order] # (n_points,)
### Build the shared morton-LBVH node topology over the sorted points.
with record_function("cluster_tree::top_down_build"):
topo = build_lbvh_topology(n_points, leaf_size, device)
### Fill leaf AABBs + total areas from the source points/areas (single
# combined pass over the compacted leaf segments), then propagate AABBs
# and areas bottom-up so each internal node summarises its subtree.
aabb_min_buf = torch.full(
(topo.max_nodes, D), float("inf"), dtype=dtype, device=device
)
aabb_max_buf = torch.full(
(topo.max_nodes, D), float("-inf"), dtype=dtype, device=device
)
total_area_buf = torch.zeros(topo.max_nodes, dtype=dtype, device=device)
with record_function("cluster_tree::leaf_aggregates"):
_fill_leaf_aggregates(
topo.leaf_node_ids,
topo.leaf_starts,
topo.leaf_sizes,
sorted_points,
sorted_areas,
aabb_min_buf,
aabb_max_buf,
total_area_buf,
)
with record_function("cluster_tree::bottom_up_aabb"):
for level_node_ids in reversed(topo.internal_nodes_per_level):
left = topo.left_child[level_node_ids]
right = topo.right_child[level_node_ids]
aabb_min_buf[level_node_ids] = torch.minimum(
aabb_min_buf[left], aabb_min_buf[right]
)
aabb_max_buf[level_node_ids] = torch.maximum(
aabb_max_buf[left], aabb_max_buf[right]
)
total_area_buf[level_node_ids] = (
total_area_buf[left] + total_area_buf[right]
)
### Compute squared AABB diagonals
node_count = topo.node_count
aabb_min_trimmed = aabb_min_buf[:node_count]
aabb_max_trimmed = aabb_max_buf[:node_count]
diameter_sq = (aabb_max_trimmed - aabb_min_trimmed).pow(2).sum(dim=-1)
logger.debug(
"ClusterTree: %d points -> %d nodes, depth %d, leaf_size=%d",
n_points,
node_count,
topo.max_depth,
leaf_size,
)
return cls(
node_aabb_min=aabb_min_trimmed,
node_aabb_max=aabb_max_trimmed,
node_diameter_sq=diameter_sq,
node_left_child=topo.left_child[:node_count],
node_right_child=topo.right_child[:node_count],
leaf_start=topo.leaf_start[:node_count],
leaf_count=topo.leaf_count[:node_count],
node_range_start=topo.range_start[:node_count],
node_range_count=topo.range_count[:node_count],
node_total_area=total_area_buf[:node_count],
sorted_source_order=sorted_order,
source_points=points,
max_depth=torch.tensor(topo.max_depth, dtype=torch.long, device=device),
batch_size=torch.Size([]),
)
[docs]
def compute_source_aggregates(
self,
source_points: Float[torch.Tensor, "n_sources n_dims"],
areas: Float[torch.Tensor, " n_sources"],
source_data: TensorDict | None = None,
) -> "SourceAggregates":
r"""Compute per-node aggregate source data for far-field approximation.
Aggregates are area-weighted averages of source features within each
node's subtree. The ``areas`` passed to this call are authoritative for
both the weighted sums and their normalization; they need not match the
areas used to construct the tree. Per-source strengths are handled
separately during kernel evaluation.
Parameters
----------
source_points : Float[torch.Tensor, "n_sources n_dims"]
Source coordinates, shape :math:`(N, D)`.
areas : Float[torch.Tensor, "n_sources"]
Per-source area weights, shape :math:`(N,)`.
source_data : TensorDict or None
Per-source features (normals, latents, etc.) with
``batch_size=(N,)``. ``None`` if no per-source features.
Returns
-------
SourceAggregates
Per-node aggregated centroids and source data.
Notes
-----
Tree topology depends on point positions, so callers may reuse a cached
tree with different aggregation weights. ``node_total_area`` remains
construction-time metadata; it is not the normalization for this
call's aggregates.
"""
device = source_points.device
dtype = source_points.dtype
D = source_points.shape[1]
n_nodes = self.n_nodes
if n_nodes == 0:
return SourceAggregates(
node_centroid=torch.empty((0, D), dtype=dtype, device=device),
node_source_data=None,
)
### Range-sum aggregation via morton-sorted prefix sums.
# Each node covers a contiguous range
# [node_range_start, node_range_start + node_range_count) in
# morton-sorted source order, so any node-subtree sum is just
# ``prefix[end] - prefix[start]``. This replaces the old
# leaf-aggregation + bottom-up Python loop, which were the
# dominant CPU + GPU costs in ``compute_source_aggregates``
# (~2 s combined per training step in profiling).
#
# The cumsum and the range subtract are done in fp64 because fp32
# suffers catastrophic cancellation when ``range_sum << cumsum_total``,
# which is the regime of small leaves (``leaf_size=1``) in a large
# tree built over offset (e.g. all-positive) coordinates. At
# drivaer scale (``N=1M``, coords ~5 m), fp32 leaf centroids had
# median ~2 % relative error and p99 ~100 % wrong. Lifting the
# cumsum to fp64 brings this back to fp32 epsilon (~1e-7) and adds
# <1 % wall-clock to the training step (cumsum is ~2.3x slower in
# fp64, but cumsum is a tiny fraction of step time). CUDA fp32
# cumsum is also non-deterministic across runs (pytorch#75240);
# fp64 cumsum is much less affected.
sorted_points = source_points[self.sorted_source_order]
sorted_areas = areas[self.sorted_source_order]
# Both the numerator and denominator must use the call-time weights.
sorted_areas_64 = sorted_areas.double()
weighted_points_64 = (sorted_points * sorted_areas.unsqueeze(-1)).double()
### Leading-zero padding makes ``prefix[i]`` the sum of the first
### ``i`` elements, so subtraction gives the half-open range sum.
cumsum_weighted_points = F.pad(
torch.cumsum(weighted_points_64, dim=0), (0, 0, 1, 0)
)
cumsum_areas = F.pad(torch.cumsum(sorted_areas_64, dim=0), (1, 0))
starts = self.node_range_start
ends = starts + self.node_range_count
node_total_weighted_pts = (
cumsum_weighted_points[ends] - cumsum_weighted_points[starts]
)
node_total_area_64 = cumsum_areas[ends] - cumsum_areas[starts]
nonzero_total = node_total_area_64 != 0
safe_areas_64 = node_total_area_64.where(nonzero_total, 1)
with record_function("cluster_tree::node_centroids"):
centroid_64 = node_total_weighted_pts / safe_areas_64.unsqueeze(-1)
centroid_buf = centroid_64.where(nonzero_total.unsqueeze(-1), 0).to(
source_points.dtype
)
node_source_data: TensorDict | None = None
if source_data is not None:
sorted_source_data = source_data[self.sorted_source_order]
def _aggregate_via_prefix_sum(tensor: torch.Tensor) -> torch.Tensor:
trailing_shape = tensor.shape[1:]
### Flatten trailing dims so the prefix sum is over a
### single feature axis - avoids materialising a
### per-feature kernel chain inside ``cumsum``. Same fp64
### upcast rationale as the centroid branch above.
flat = tensor.reshape(tensor.shape[0], -1)
weighted_64 = (flat * sorted_areas.unsqueeze(-1)).double()
cumsum_weighted = F.pad(torch.cumsum(weighted_64, dim=0), (0, 0, 1, 0))
node_weighted_sum = cumsum_weighted[ends] - cumsum_weighted[starts]
node_avg = node_weighted_sum / safe_areas_64.unsqueeze(-1)
node_avg = node_avg.where(nonzero_total.unsqueeze(-1), 0)
return node_avg.reshape((n_nodes,) + trailing_shape).to(tensor.dtype)
with record_function("cluster_tree::node_source_data"):
node_source_data = sorted_source_data.apply(
_aggregate_via_prefix_sum, batch_size=[n_nodes]
)
return SourceAggregates(
node_centroid=centroid_buf,
node_source_data=node_source_data,
)
[docs]
def find_dual_interaction_pairs(
self,
target_tree: "ClusterTree",
theta: float = 1.0,
*,
expand_far_targets: bool = False,
) -> DualInteractionPlan:
r"""Find near-field and far-field pairs via dual-tree traversal.
Traverses both the source tree (``self``) and ``target_tree``
simultaneously. For well-separated node pairs, records a single
far-field (target_node, source_node) entry - the kernel is evaluated
ONCE at the node centroids and broadcast to all targets in the node.
This reduces far-field kernel evaluations from O(N log N) to O(N).
Uses a combined AABB-distance opening criterion:
``(D_T + D_S) / r < theta``, where D_T and D_S are the AABB
diagonals and r is the minimum distance between the two AABBs.
This accounts for approximation error on both the target and
source sides.
Parameters
----------
target_tree : ClusterTree
Tree over target points. For self-interaction (communication
layers), this is the same object as ``self``.
theta : float
Barnes-Hut opening angle. Larger = more aggressive.
``theta = 0`` forces all interactions to be exact.
expand_far_targets : bool, optional, default=False
If ``True``, far-field node pairs are expanded to individual
target points, converting ``(far, far)`` entries into
``(near, far)`` entries. This eliminates the target-side
centroid approximation (and the blocky spatial artifacts it
produces) at the cost of more kernel evaluations while
preserving the source-side monopole speedup.
Returns
-------
DualInteractionPlan
Near-field individual pairs and far-field node-to-node pairs.
"""
source_tree = self
device = source_tree.node_aabb_min.device
theta_sq = theta * theta
### Handle empty trees
if source_tree.n_nodes == 0 or target_tree.n_nodes == 0:
empty = torch.empty(0, dtype=torch.long, device=device)
return DualInteractionPlan(
near_target_ids=empty,
near_source_ids=empty.clone(),
far_target_node_ids=empty.clone(),
far_source_node_ids=empty.clone(),
nf_target_ids=empty.clone(),
nf_source_node_ids=empty.clone(),
fn_target_node_ids=empty.clone(),
fn_source_ids=empty.clone(),
fn_broadcast_targets=empty.clone(),
fn_broadcast_starts=empty.clone(),
fn_broadcast_counts=empty.clone(),
)
with record_function("cluster_tree::dual_traversal"):
### Initialize: root-to-root pair
active_tgt_nodes = torch.zeros(1, dtype=torch.long, device=device)
active_src_nodes = torch.zeros(1, dtype=torch.long, device=device)
### Output streams. All per-iteration outputs use a
### deferred-compaction protocol: the per-element tensor is
### accumulated unfiltered together with a boolean validity
### mask, and ONE compaction is paid per stream at the end of
### the traversal. This trades five per-iteration ``nonzero``
### syncs inside the (near,far)/(far,near)/broadcast paths plus
### two per-iteration syncs in the far path for a fixed handful
### of end-of-loop syncs.
far_tgt_unfiltered_list: list[torch.Tensor] = []
far_src_unfiltered_list: list[torch.Tensor] = []
far_validity_list: list[torch.Tensor] = []
### (near,near) output from leaf-leaf expansion is already
### compacted by the ``_ragged_arange`` inside the expansion
### (its output size is set by the Cartesian total anyway),
### so no per-stream validity mask is needed here.
near_target_list: list[torch.Tensor] = []
near_source_list: list[torch.Tensor] = []
### (near,far) stream has two append paths:
### - ``expand_far_targets=True``: already-filtered entries
### from the ``_ragged_arange``-with-masked-counts branch
### below. No validity mask needed.
### - ``_expand_dual_leaf_hits``: unfiltered targets +
### ``nf_validity`` mask (length ``t_full`` per iter).
### Kept in separate lists so the deferred path's compaction
### at end-of-traversal does not touch the already-filtered
### entries.
nf_filtered_target_list: list[torch.Tensor] = []
nf_filtered_source_node_list: list[torch.Tensor] = []
nf_deferred_target_list: list[torch.Tensor] = []
nf_deferred_source_node_list: list[torch.Tensor] = []
nf_deferred_validity_list: list[torch.Tensor] = []
### (far,near) + broadcast mapping from ``_expand_dual_leaf_hits``.
### Both the per-source tensors and the per-source broadcast
### starts/counts are stored unfiltered against the same
### ``fn_validity`` (``= source_is_far``); the broadcast
### targets buffer carries its own ``fn_bcast_validity`` mask
### that drops the sentinel-padded tail of each iter.
fn_deferred_tgt_node_list: list[torch.Tensor] = []
fn_deferred_src_list: list[torch.Tensor] = []
fn_deferred_validity_list: list[torch.Tensor] = []
fn_bcast_starts_list: list[torch.Tensor] = []
fn_bcast_counts_list: list[torch.Tensor] = []
fn_bcast_targets_list: list[torch.Tensor] = []
fn_bcast_validity_list: list[torch.Tensor] = []
fn_bcast_offset = 0
### Loop bound: every iteration descends at least one tree level
### on at least one side, so ``2 * total_levels + safety`` is a
### hard upper bound that requires no GPU->CPU read. Using
### ``int(max_depth.item())`` as before would force two syncs
### per call before we even start the loop.
n_src_levels = max(1, int(source_tree.n_sources).bit_length())
n_tgt_levels = max(1, int(target_tree.n_sources).bit_length())
max_iters = 2 * (n_src_levels + n_tgt_levels) + 4
depth = 0
for depth in range(max_iters):
### ``numel()`` is a shape query (Python int), not a sync.
if active_tgt_nodes.numel() == 0:
break
### Combined opening criterion: minimum AABB-to-AABB gap.
# For each dimension, the gap is the positive distance
# between the two boxes (zero if they overlap).
aabb_min_T = target_tree.node_aabb_min[active_tgt_nodes]
aabb_max_T = target_tree.node_aabb_max[active_tgt_nodes]
aabb_min_S = source_tree.node_aabb_min[active_src_nodes]
aabb_max_S = source_tree.node_aabb_max[active_src_nodes]
gap = torch.clamp(
torch.maximum(aabb_min_T - aabb_max_S, aabb_min_S - aabb_max_T),
min=0,
)
min_dist_sq = gap.pow(2).sum(dim=-1)
diam_sq_T = target_tree.node_diameter_sq[active_tgt_nodes]
diam_sq_S = source_tree.node_diameter_sq[active_src_nodes]
diam_T = diam_sq_T.sqrt()
diam_S = diam_sq_S.sqrt()
combined_diam_sq = (diam_T + diam_S).pow(2)
is_far = min_dist_sq * theta_sq > combined_diam_sq
### Classify active pairs (boolean masks over the full
### active set; combined later via ``need_split``).
is_leaf_T = target_tree.leaf_count[active_tgt_nodes] > 0
is_leaf_S = source_tree.leaf_count[active_src_nodes] > 0
near_leaf_leaf = (~is_far) & is_leaf_T & is_leaf_S
need_split = (~is_far) & (~near_leaf_leaf)
### 1. Far-field: deferred-compaction path.
if expand_far_targets:
### Mask counts to zero for non-far entries; the ragged
### expansion then naturally skips them. ``pair_ids``
### indexes back into the *full* active set so we never
### need a separate filtered ``far_src_nids`` tensor.
starts_full = target_tree.node_range_start[active_tgt_nodes]
counts_full = target_tree.node_range_count[active_tgt_nodes]
counts_masked = torch.where(
is_far, counts_full, torch.zeros_like(counts_full)
)
positions, pair_ids = _ragged_arange(starts_full, counts_masked)
nf_filtered_target_list.append(
target_tree.sorted_source_order[positions]
)
nf_filtered_source_node_list.append(active_src_nodes[pair_ids])
else:
far_tgt_unfiltered_list.append(active_tgt_nodes)
far_src_unfiltered_list.append(active_src_nodes)
far_validity_list.append(is_far)
### 2. Near-field, both leaves: two-stage deferred expansion.
# ``_expand_dual_leaf_hits`` returns the (near,far),
# (far,near), and broadcast streams unfiltered (with
# validity masks); the caller compacts them once at the
# end of the traversal. Only the (near,near) Cartesian-
# product output (whose size is data-dependent regardless)
# is already compacted.
nll_idx = near_leaf_leaf.nonzero(as_tuple=True)[0]
hits = _expand_dual_leaf_hits(
active_tgt_nodes[nll_idx],
active_src_nodes[nll_idx],
target_tree,
source_tree,
theta,
)
near_target_list.append(hits.near_tgts)
near_source_list.append(hits.near_srcs)
nf_deferred_target_list.append(hits.nf_tgts)
nf_deferred_source_node_list.append(hits.nf_snids)
nf_deferred_validity_list.append(hits.nf_validity)
fn_deferred_tgt_node_list.append(hits.fn_tnids)
fn_deferred_src_list.append(hits.fn_sids)
fn_deferred_validity_list.append(hits.fn_validity)
fn_bcast_starts_list.append(hits.fn_bcast_starts + fn_bcast_offset)
fn_bcast_counts_list.append(hits.fn_bcast_counts)
fn_bcast_targets_list.append(hits.fn_bcast_targets)
fn_bcast_validity_list.append(hits.fn_bcast_targets_validity)
fn_bcast_offset += hits.fn_bcast_targets.shape[0]
### 3. Generate next iteration's active set.
# We compute children over the FULL active set (n_active
# entries) and use validity masks per (T,S) child slot to
# encode the case-A / case-B / case-C splitting rules from
# the original implementation. After unioning the eight
# potential child slots we pay ONE boolean compaction
# instead of the original ~12 ``.any()``-gated indexings.
do_split_T = (~is_leaf_T) & (is_leaf_S | (diam_sq_T >= diam_sq_S))
do_split_S = (~is_leaf_S) & (is_leaf_T | (diam_sq_S >= diam_sq_T))
case_T_only = need_split & do_split_T & (~do_split_S)
case_S_only = need_split & do_split_S & (~do_split_T)
case_both = need_split & do_split_T & do_split_S
left_T = target_tree.node_left_child[active_tgt_nodes]
right_T = target_tree.node_right_child[active_tgt_nodes]
left_S = source_tree.node_left_child[active_src_nodes]
right_S = source_tree.node_right_child[active_src_nodes]
left_T_ok = left_T >= 0
right_T_ok = right_T >= 0
left_S_ok = left_S >= 0
right_S_ok = right_S >= 0
### Eight child-pair slots: each is (t_ids, s_ids, validity)
### where every tensor has shape ``(n_active,)``.
# 1: case_T_only, (left_T, parent_S)
# 2: case_T_only, (right_T, parent_S)
# 3: case_S_only, (parent_T, left_S)
# 4: case_S_only, (parent_T, right_S)
# 5: case_both, (left_T, left_S)
# 6: case_both, (left_T, right_S)
# 7: case_both, (right_T, left_S)
# 8: case_both, (right_T, right_S)
slot_t = torch.stack(
[
left_T,
right_T,
active_tgt_nodes,
active_tgt_nodes,
left_T,
left_T,
right_T,
right_T,
]
)
slot_s = torch.stack(
[
active_src_nodes,
active_src_nodes,
left_S,
right_S,
left_S,
right_S,
left_S,
right_S,
]
)
slot_v = torch.stack(
[
case_T_only & left_T_ok,
case_T_only & right_T_ok,
case_S_only & left_S_ok,
case_S_only & right_S_ok,
case_both & left_T_ok & left_S_ok,
case_both & left_T_ok & right_S_ok,
case_both & right_T_ok & left_S_ok,
case_both & right_T_ok & right_S_ok,
]
)
### One sync per iteration: the boolean compaction below.
### Each ``tensor[bool_mask]`` lowers to ``aten::nonzero``;
### computing ``keep_idx`` explicitly once and integer-
### indexing both ``slot_t`` and ``slot_s`` collapses the
### two nonzero syncs into one.
flat_v = slot_v.reshape(-1)
keep_idx = flat_v.nonzero(as_tuple=True)[0]
active_tgt_nodes = slot_t.reshape(-1)[keep_idx]
active_src_nodes = slot_s.reshape(-1)[keep_idx]
### Concatenate accumulated pairs and pay one boolean
### compaction per output stream, all at end-of-traversal.
### See :func:`_compact_deferred` and
### :func:`_compact_sentinel_padded` for the protocol.
empty_long = torch.empty(0, dtype=torch.long, device=device)
near_tgt = (
torch.cat(near_target_list) if near_target_list else empty_long.clone()
)
near_src = (
torch.cat(near_source_list) if near_source_list else empty_long.clone()
)
### Far-field stream: deferred (unfiltered + validity).
far_tgt_nid, far_src_nid = _compact_deferred(
far_tgt_unfiltered_list,
far_src_unfiltered_list,
validity_list=far_validity_list,
device=device,
)
### (near, far) stream: combine deferred entries from
### ``_expand_dual_leaf_hits`` with the already-filtered
### entries from the ``expand_far_targets=True`` branch.
nf_def_tgt, nf_def_snid = _compact_deferred(
nf_deferred_target_list,
nf_deferred_source_node_list,
validity_list=nf_deferred_validity_list,
device=device,
)
nf_tgt = (
torch.cat([nf_def_tgt, *nf_filtered_target_list])
if nf_filtered_target_list
else nf_def_tgt
)
nf_snid = (
torch.cat([nf_def_snid, *nf_filtered_source_node_list])
if nf_filtered_source_node_list
else nf_def_snid
)
### (far, near) + broadcast streams. The fn tensors and
### the per-source ``fn_bcast_starts/counts`` are aligned
### with ``fn_validity`` (= ``source_is_far``) and compact
### together. ``fn_broadcast_targets`` is sentinel-padded
### on the *t_full* axis and compacts separately via its
### own validity mask, with ``fn_bcast_starts`` remapped
### into the compacted space.
if fn_deferred_validity_list:
fn_tnid, fn_sid, fn_bstarts_padded, fn_bcounts = _compact_deferred(
fn_deferred_tgt_node_list,
fn_deferred_src_list,
fn_bcast_starts_list,
fn_bcast_counts_list,
validity_list=fn_deferred_validity_list,
device=device,
)
fn_btgts, fn_bstarts = _compact_sentinel_padded(
torch.cat(fn_bcast_targets_list),
fn_bstarts_padded,
torch.cat(fn_bcast_validity_list),
)
else:
fn_tnid = empty_long.clone()
fn_sid = empty_long.clone()
fn_btgts = empty_long.clone()
fn_bstarts = empty_long.clone()
fn_bcounts = empty_long.clone()
### Group each output stream by source index (or source node)
### for coalesced downstream gathers. See :func:`_sort_by_key`.
near_tgt, near_src = _sort_by_key(near_tgt, near_src, key=near_src)
far_tgt_nid, far_src_nid = _sort_by_key(
far_tgt_nid, far_src_nid, key=far_src_nid
)
nf_tgt, nf_snid = _sort_by_key(nf_tgt, nf_snid, key=nf_snid)
fn_tnid, fn_sid, fn_bstarts, fn_bcounts = _sort_by_key(
fn_tnid, fn_sid, fn_bstarts, fn_bcounts, key=fn_sid
)
plan = DualInteractionPlan(
near_target_ids=near_tgt,
near_source_ids=near_src,
far_target_node_ids=far_tgt_nid,
far_source_node_ids=far_src_nid,
nf_target_ids=nf_tgt,
nf_source_node_ids=nf_snid,
fn_target_node_ids=fn_tnid,
fn_source_ids=fn_sid,
fn_broadcast_targets=fn_btgts,
fn_broadcast_starts=fn_bstarts,
fn_broadcast_counts=fn_bcounts,
)
if not torch.compiler.is_compiling():
plan.validate()
is_self = target_tree is self
logger.debug(
"dual traversal: %d near + %d nf + %d fn + %d far_node pairs, "
"theta=%.2f, self_interaction=%s, %d iterations",
plan.n_near,
plan.n_nf,
plan.n_fn,
plan.n_far_nodes,
theta,
is_self,
depth,
)
return plan
# ---------------------------------------------------------------------------
# SourceAggregates: per-node aggregate data for far-field approximation
# ---------------------------------------------------------------------------
[docs]
@tensorclass
class SourceAggregates:
"""Per-node aggregated source data for far-field monopole approximation.
Computed by :meth:`ClusterTree.compute_source_aggregates` and consumed
by :class:`BarnesHutKernel` during kernel evaluation.
"""
node_centroid: Float[torch.Tensor, "n_nodes n_dims"]
"""Area-weighted centroid per node."""
node_source_data: TensorDict | None
"""Area-weighted average source features per node, or ``None`` if no
per-source features. Has ``batch_size=(n_nodes,)``."""
# ---------------------------------------------------------------------------
# Internal helpers for tree construction
# ---------------------------------------------------------------------------
def _fill_leaf_aggregates(
leaf_nids: Int[torch.Tensor, " n_leaves"],
leaf_starts: Int[torch.Tensor, " n_leaves"],
leaf_sizes: Int[torch.Tensor, " n_leaves"],
sorted_points: Float[torch.Tensor, "n_sorted_sources n_dims"],
sorted_areas: Float[torch.Tensor, " n_sorted_sources"],
aabb_min_buf: Float[torch.Tensor, "n_nodes n_dims"],
aabb_max_buf: Float[torch.Tensor, "n_nodes n_dims"],
total_area_buf: Float[torch.Tensor, " n_nodes"],
) -> None:
"""Fill leaf AABB and total-area buffers in one segmented reduction pass.
AABB and area aggregations share the same per-source ``(positions,
seg_ids)`` mapping from ``_ragged_arange``; doing them together
halves the ragged-arange work and avoids a redundant
``int(leaf_sizes.sum())`` sync that the previous separate
``_fill_leaf_aabbs`` / ``_fill_leaf_total_areas`` helpers each paid.
Empty inputs (``n_leaves == 0``) are a no-op via the early return.
"""
n_leaves = leaf_nids.shape[0]
if n_leaves == 0:
return
device = leaf_nids.device
D = sorted_points.shape[1]
dtype = sorted_points.dtype
positions, seg_ids = _ragged_arange(leaf_starts, leaf_sizes)
pts = sorted_points[positions]
areas_per_pos = sorted_areas[positions]
seg_min = torch.full((n_leaves, D), float("inf"), dtype=dtype, device=device)
seg_max = torch.full((n_leaves, D), float("-inf"), dtype=dtype, device=device)
exp_ids = seg_ids.unsqueeze(1).expand_as(pts)
seg_min.scatter_reduce_(0, exp_ids, pts, reduce="amin", include_self=True)
seg_max.scatter_reduce_(0, exp_ids, pts, reduce="amax", include_self=True)
leaf_areas = torch.zeros(n_leaves, dtype=areas_per_pos.dtype, device=device)
leaf_areas.scatter_add_(0, seg_ids, areas_per_pos)
aabb_min_buf[leaf_nids] = seg_min
aabb_max_buf[leaf_nids] = seg_max
total_area_buf[leaf_nids] = leaf_areas