Skip to content

Commit 80fe1fb

Browse files
committed
add recursive clustering and skeletonization
1 parent d65f31a commit 80fe1fb

13 files changed

+785
-195
lines changed

doc/conf.py

+4
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@
2222
"DOFDescriptorLike": "pytential.symbolic.dof_desc.DOFDescriptorLike",
2323
}
2424

25+
nitpick_ignore_regex = [
26+
["py:class", r"_ProxyNeighborEvaluationResult"],
27+
]
28+
2529
intersphinx_mapping = {
2630
"https://docs.python.org/3/": None,
2731
"https://numpy.org/doc/stable/": None,

doc/linalg.rst

+18-4
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ scheme is used:
88
component of the Stokeslet.
99
* ``cluster`` refers to a piece of a ``block`` as used by the recursive
1010
proxy-based skeletonization of the direct solver algorithms. Clusters
11-
are represented by a :class:`~pytential.linalg.TargetAndSourceClusterList`.
11+
are represented by a :class:`~pytential.linalg.utils.TargetAndSourceClusterList`.
1212

1313
GMRES
1414
-----
@@ -20,17 +20,31 @@ GMRES
2020
Hierarchical Direct Solver
2121
--------------------------
2222

23+
.. note::
24+
25+
High-level API for direct solvers is in progress.
26+
27+
Low-level Functionality
28+
-----------------------
29+
2330
.. warning::
2431

2532
All the classes and routines in this module are experimental and the
2633
API can change at any point.
2734

35+
.. automodule:: pytential.linalg.skeletonization
36+
.. automodule:: pytential.linalg.cluster
2837
.. automodule:: pytential.linalg.proxy
29-
.. automodule:: pytential.linalg.utils
3038

31-
Internal Functionality
32-
----------------------
39+
Internal Functionality and Utilities
40+
------------------------------------
41+
42+
.. warning::
3343

44+
All the classes and routines in this module are experimental and the
45+
API can change at any point.
46+
47+
.. automodule:: pytential.linalg.utils
3448
.. automodule:: pytential.linalg.direct_solver_symbolic
3549

3650
.. vim: sw=4:tw=75:fdm=marker

pytential/linalg/__init__.py

-16
Original file line numberDiff line numberDiff line change
@@ -25,25 +25,9 @@
2525
make_index_list, make_index_cluster_cartesian_product,
2626
interp_decomp,
2727
)
28-
from pytential.linalg.proxy import (
29-
ProxyClusterGeometryData, ProxyPointTarget, ProxyPointSource,
30-
ProxyGeneratorBase, ProxyGenerator, QBXProxyGenerator,
31-
partition_by_nodes, gather_cluster_neighbor_points,
32-
)
33-
from pytential.linalg.skeletonization import (
34-
SkeletonizationWrangler, make_skeletonization_wrangler,
35-
SkeletonizationResult, skeletonize_by_proxy,
36-
)
3728

3829
__all__ = (
3930
"IndexList", "TargetAndSourceClusterList",
4031
"make_index_list", "make_index_cluster_cartesian_product",
4132
"interp_decomp",
42-
43-
"ProxyClusterGeometryData", "ProxyPointTarget", "ProxyPointSource",
44-
"ProxyGeneratorBase", "ProxyGenerator", "QBXProxyGenerator",
45-
"partition_by_nodes", "gather_cluster_neighbor_points",
46-
47-
"SkeletonizationWrangler", "make_skeletonization_wrangler",
48-
"SkeletonizationResult", "skeletonize_by_proxy",
4933
)

pytential/linalg/cluster.py

+315
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
__copyright__ = "Copyright (C) 2022 Alexandru Fikl"
2+
3+
__license__ = """
4+
Permission is hereby granted, free of charge, to any person obtaining a copy
5+
of this software and associated documentation files (the "Software"), to deal
6+
in the Software without restriction, including without limitation the rights
7+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8+
copies of the Software, and to permit persons to whom the Software is
9+
furnished to do so, subject to the following conditions:
10+
11+
The above copyright notice and this permission notice shall be included in
12+
all copies or substantial portions of the Software.
13+
14+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
20+
THE SOFTWARE.
21+
"""
22+
23+
from dataclasses import dataclass, replace
24+
from functools import singledispatch
25+
from typing import Iterator, Optional
26+
27+
import numpy as np
28+
29+
from pytools import T
30+
31+
from arraycontext import PyOpenCLArrayContext
32+
from boxtree.tree import Tree
33+
from pytential import sym, GeometryCollection
34+
from pytential.linalg.utils import IndexList, TargetAndSourceClusterList
35+
36+
__doc__ = """
37+
Clustering
38+
~~~~~~~~~~
39+
40+
.. autoclass:: ClusterLevel
41+
.. autoclass:: ClusterTree
42+
43+
.. autofunction:: cluster
44+
.. autofunction:: partition_by_nodes
45+
"""
46+
47+
# FIXME: this is just an arbitrary value
48+
_DEFAULT_MAX_PARTICLES_IN_BOX = 32
49+
50+
51+
# {{{ cluster tree
52+
53+
54+
def make_cluster_parent_map(parent_ids: np.ndarray) -> np.ndarray:
55+
"""Construct a parent map for :attr:`ClusterLevel.parent_map`."""
56+
# NOTE: np.unique returns a sorted array
57+
unique_parent_ids = np.unique(parent_ids)
58+
# find the index of each parent id
59+
unique_parent_index = np.searchsorted(unique_parent_ids, parent_ids)
60+
61+
unique_parent_map = np.empty(unique_parent_ids.size, dtype=object)
62+
for i in range(unique_parent_ids.size):
63+
unique_parent_map[i], = np.nonzero(unique_parent_index == i)
64+
65+
return unique_parent_map
66+
67+
68+
@dataclass(frozen=True)
69+
class ClusterLevel:
70+
"""A level in a :class:`ClusterTree`.
71+
72+
.. attribute:: level
73+
74+
Current level that is represented.
75+
76+
.. attribute:: nclusters
77+
78+
Number of clusters on the current level (same as number of boxes
79+
in :attr:`box_ids`).
80+
81+
.. attribute:: box_ids
82+
83+
Box IDs on the current level.
84+
85+
.. attribute:: parent_map
86+
87+
An object :class:`~numpy.ndarray`, where each entry maps a parent
88+
to its children indices in :attr:`box_ids`. This can be used to
89+
:func:`cluster` all indices in ``parent_map[i]`` into their
90+
parent.
91+
"""
92+
93+
level: int
94+
box_ids: np.ndarray
95+
parent_map: np.ndarray
96+
97+
@property
98+
def nclusters(self):
99+
return self.box_ids.size
100+
101+
102+
@dataclass(frozen=True)
103+
class ClusterTree:
104+
"""Hierarhical cluster representation.
105+
106+
.. attribute:: nlevels
107+
108+
Total number of levels in the tree.
109+
110+
.. attribute:: leaf_cluster_box_ids
111+
112+
Box IDs for each cluster on the leaf level of the tree.
113+
114+
.. attribute:: tree_cluster_parent_ids
115+
116+
Parent box IDs for :attr:`leaf_cluster_box_ids`.
117+
118+
.. automethod:: levels
119+
"""
120+
121+
nlevels: int
122+
leaf_cluster_box_ids: np.ndarray
123+
tree_cluster_parent_ids: np.ndarray
124+
125+
# NOTE: only here to allow easier debugging + testing
126+
_tree: Optional[Tree]
127+
128+
@property
129+
def nclusters(self):
130+
return self.leaf_cluster_box_ids.size
131+
132+
def levels(self) -> Iterator[ClusterLevel]:
133+
"""
134+
:returns: an iterator over all the :class:`ClusterLevel` levels.
135+
"""
136+
137+
box_ids = self.leaf_cluster_box_ids
138+
parent_ids = self.tree_cluster_parent_ids[box_ids]
139+
clevel = ClusterLevel(
140+
level=self.nlevels - 1,
141+
box_ids=box_ids,
142+
parent_map=make_cluster_parent_map(parent_ids),
143+
)
144+
145+
for _ in range(self.nlevels - 1, 0, -1):
146+
yield clevel
147+
148+
box_ids = np.unique(self.tree_cluster_parent_ids[clevel.box_ids])
149+
parent_ids = self.tree_cluster_parent_ids[box_ids]
150+
clevel = ClusterLevel(
151+
level=clevel.level - 1,
152+
box_ids=box_ids,
153+
parent_map=make_cluster_parent_map(parent_ids)
154+
)
155+
156+
assert clevel.nclusters == 1
157+
158+
159+
@singledispatch
160+
def cluster(obj: T, ctree: ClusterTree) -> T:
161+
"""Merge together elements of *obj* into their parent object, as described
162+
by *ctree*.
163+
"""
164+
raise NotImplementedError(type(obj).__name__)
165+
166+
167+
@cluster.register(IndexList)
168+
def _cluster_index_list(obj: IndexList, clevel: ClusterLevel) -> IndexList:
169+
assert obj.nclusters == clevel.nclusters
170+
171+
if clevel.nclusters == 1:
172+
return obj
173+
174+
sizes = [sum([obj.cluster_size(j) for j in ppm]) for ppm in clevel.parent_map]
175+
return replace(obj, starts=np.cumsum([0] + sizes))
176+
177+
178+
@cluster.register(TargetAndSourceClusterList)
179+
def _cluster_target_and_source_cluster_list(
180+
obj: TargetAndSourceClusterList, clevel: ClusterLevel,
181+
) -> TargetAndSourceClusterList:
182+
assert obj.nclusters == clevel.nclusters
183+
184+
if clevel.nclusters == 1:
185+
return obj
186+
187+
return replace(obj,
188+
targets=cluster(obj.targets, clevel),
189+
sources=cluster(obj.sources, clevel))
190+
191+
192+
@cluster.register(np.ndarray)
193+
def _cluster_ndarray(obj: np.ndarray, clevel: ClusterLevel) -> np.ndarray:
194+
assert obj.shape == (clevel.nclusters,)
195+
assert all(block.ndim == 2 for block in obj)
196+
197+
if clevel.nclusters == 1:
198+
return obj
199+
200+
def make_block(i: int, j: int):
201+
if i == j:
202+
return obj[i]
203+
204+
return np.zeros((obj[i].shape[0], obj[j].shape[1]), dtype=obj[i].dtype)
205+
206+
from pytools.obj_array import make_obj_array
207+
return make_obj_array([
208+
np.block([[make_block(i, j) for j in ppm] for i in ppm])
209+
for ppm in clevel.parent_map
210+
])
211+
212+
# }}}
213+
214+
215+
# {{{ cluster generation
216+
217+
def _build_binary_ish_tree_from_indices(starts: np.ndarray) -> ClusterTree:
218+
partition_box_ids = np.arange(starts.size - 1)
219+
220+
box_ids = partition_box_ids
221+
222+
box_parent_ids = []
223+
offset = box_ids.size
224+
while box_ids.size > 1:
225+
# NOTE: this is probably not the most efficient way to do it, but this
226+
# code is mostly meant for debugging using a simple tree
227+
clusters = np.array_split(box_ids, box_ids.size // 2)
228+
parent_ids = offset + np.arange(len(clusters))
229+
box_parent_ids.append(np.repeat(parent_ids, [len(c) for c in clusters]))
230+
231+
box_ids = parent_ids
232+
offset += box_ids.size
233+
234+
# NOTE: make the root point to itself
235+
box_parent_ids.append(np.array([offset - 1]))
236+
nlevels = len(box_parent_ids)
237+
238+
return ClusterTree(
239+
nlevels=nlevels,
240+
leaf_cluster_box_ids=partition_box_ids,
241+
tree_cluster_parent_ids=np.concatenate(box_parent_ids),
242+
_tree=None)
243+
244+
245+
def partition_by_nodes(
246+
actx: PyOpenCLArrayContext, places: GeometryCollection, *,
247+
dofdesc: Optional[sym.DOFDescriptorLike] = None,
248+
tree_kind: Optional[str] = "adaptive-level-restricted",
249+
max_particles_in_box: Optional[int] = None) -> IndexList:
250+
"""Generate equally sized ranges of nodes. The partition is created at the
251+
lowest level of granularity, i.e. nodes. This results in balanced ranges
252+
of points, but will split elements across different ranges.
253+
254+
:arg dofdesc: a :class:`~pytential.symbolic.dof_desc.DOFDescriptor` for
255+
the geometry in *places* which should be partitioned.
256+
:arg tree_kind: if not *None*, it is passed to :class:`boxtree.TreeBuilder`.
257+
:arg max_particles_in_box: value used to control the number of points
258+
in each partition (and thus the number of partitions). See the documentation
259+
in :class:`boxtree.TreeBuilder`.
260+
"""
261+
if dofdesc is None:
262+
dofdesc = places.auto_source
263+
dofdesc = sym.as_dofdesc(dofdesc)
264+
265+
if max_particles_in_box is None:
266+
max_particles_in_box = _DEFAULT_MAX_PARTICLES_IN_BOX
267+
268+
lpot_source = places.get_geometry(dofdesc.geometry)
269+
discr = places.get_discretization(dofdesc.geometry, dofdesc.discr_stage)
270+
271+
if tree_kind is not None:
272+
from pytential.qbx.utils import tree_code_container
273+
tcc = tree_code_container(lpot_source._setup_actx)
274+
275+
from arraycontext import flatten
276+
from meshmode.dof_array import DOFArray
277+
tree, _ = tcc.build_tree()(actx.queue,
278+
particles=flatten(
279+
actx.thaw(discr.nodes()), actx, leaf_class=DOFArray
280+
),
281+
max_particles_in_box=max_particles_in_box,
282+
kind=tree_kind)
283+
284+
from boxtree import box_flags_enum
285+
tree = tree.get(actx.queue)
286+
leaf_boxes, = (tree.box_flags & box_flags_enum.HAS_CHILDREN == 0).nonzero()
287+
288+
indices = np.empty(len(leaf_boxes), dtype=object)
289+
starts = None
290+
291+
for i, ibox in enumerate(leaf_boxes):
292+
box_start = tree.box_source_starts[ibox]
293+
box_end = box_start + tree.box_source_counts_cumul[ibox]
294+
indices[i] = tree.user_source_ids[box_start:box_end]
295+
296+
ctree = ClusterTree(
297+
nlevels=tree.nlevels,
298+
leaf_cluster_box_ids=leaf_boxes,
299+
tree_cluster_parent_ids=tree.box_parent_ids,
300+
_tree=tree)
301+
else:
302+
if discr.ambient_dim != 2 and discr.dim == 1:
303+
raise ValueError("only curves are supported for 'tree_kind=None'")
304+
305+
nclusters = max(discr.ndofs // max_particles_in_box, 2)
306+
indices = np.arange(0, discr.ndofs, dtype=np.int64)
307+
starts = np.linspace(0, discr.ndofs, nclusters + 1, dtype=np.int64)
308+
assert starts[-1] == discr.ndofs
309+
310+
ctree = _build_binary_ish_tree_from_indices(starts)
311+
312+
from pytential.linalg import make_index_list
313+
return make_index_list(indices, starts=starts), ctree
314+
315+
# }}}

0 commit comments

Comments
 (0)