PyCosmoMMF 1.0.2__py3-none-any.whl → 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pycosmommf/_cupy_tagging.py +343 -0
- pycosmommf/_jax_backend.py +577 -0
- pycosmommf/_version.py +18 -15
- pycosmommf/filter.py +42 -37
- pycosmommf/hessian.py +11 -9
- pycosmommf/signatures.py +154 -19
- pycosmommf/tagging.py +326 -177
- pycosmommf/utils.py +6 -6
- {pycosmommf-1.0.2.dist-info → pycosmommf-2.0.0.dist-info}/METADATA +8 -5
- pycosmommf-2.0.0.dist-info/RECORD +15 -0
- {pycosmommf-1.0.2.dist-info → pycosmommf-2.0.0.dist-info}/WHEEL +1 -1
- pycosmommf-1.0.2.dist-info/RECORD +0 -13
- {pycosmommf-1.0.2.dist-info → pycosmommf-2.0.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GPU (CuPy) backend for structure tagging. Requires CuPy to be installed:
|
|
3
|
+
|
|
4
|
+
pip install 'pycosmommf[gpu]'
|
|
5
|
+
|
|
6
|
+
Reached only through ``calc_structure_bools(..., backend="gpu")``; CuPy is
|
|
7
|
+
imported lazily so a missing install raises ``ImportError`` only when this
|
|
8
|
+
backend is requested.
|
|
9
|
+
|
|
10
|
+
The computation mirrors ``tagging._structure_bools_cpu`` step for step and
|
|
11
|
+
shares its helpers (cluster thresholds and their selection, the log10(𝒮)
|
|
12
|
+
grid, mass change curve, report). Everything runs in float32, like the CPU
|
|
13
|
+
backend; the two backends agree up to float32 summation-order differences.
|
|
14
|
+
The differences are in how each step runs on the GPU:
|
|
15
|
+
|
|
16
|
+
* Clumps are labelled with ``cupyx.scipy.ndimage.label`` using the full
|
|
17
|
+
3×3×3 structure (26-connectivity, the ``skimage.measure.label`` default).
|
|
18
|
+
Per-clump δ sums and voxel counts use float32/int32 atomics over the
|
|
19
|
+
foreground voxels only.
|
|
20
|
+
* Each point of a mass change curve is one fused ``ReductionKernel`` pass
|
|
21
|
+
that sums the density over ``valid & (sig > threshold)`` in float32,
|
|
22
|
+
without building a mask or a compacted copy of the arrays.
|
|
23
|
+
* Inputs that already live on the GPU (CuPy arrays, JAX GPU arrays) are
|
|
24
|
+
used in place: JAX arrays are shared through DLPack without a copy.
|
|
25
|
+
|
|
26
|
+
Memory model
|
|
27
|
+
------------
|
|
28
|
+
JAX's recommended GPU allocator (``XLA_PYTHON_CLIENT_ALLOCATOR=cuda_async``)
|
|
29
|
+
keeps memory it has freed inside CUDA's stream-ordered memory pool. CuPy's
|
|
30
|
+
default pool cannot reuse that memory, so after ``maximum_signature`` ran
|
|
31
|
+
in the same process most of the GPU would look full. While tagging runs,
|
|
32
|
+
CuPy therefore allocates from the same stream-ordered pool
|
|
33
|
+
(``cupy.cuda.MemoryAsyncPool``) and releases its unused blocks at the end.
|
|
34
|
+
|
|
35
|
+
Peak GPU memory is roughly the signatures (3 fields), the density, δ and
|
|
36
|
+
the int32 clump labels (one field each) plus a few one-byte masks: about
|
|
37
|
+
7× the size of a float32 field, ~29 GiB at 1024³.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import contextlib
|
|
43
|
+
import functools
|
|
44
|
+
import warnings
|
|
45
|
+
|
|
46
|
+
import numpy as np
|
|
47
|
+
|
|
48
|
+
from .tagging import (
|
|
49
|
+
_CLUSTER,
|
|
50
|
+
_FILAMENT,
|
|
51
|
+
_VOID,
|
|
52
|
+
_WALL,
|
|
53
|
+
_check_not_overdensity,
|
|
54
|
+
_cluster_signature_thresholds,
|
|
55
|
+
_log10S_grid,
|
|
56
|
+
_mass_change_curve,
|
|
57
|
+
_select_cluster_threshold,
|
|
58
|
+
_virialized_fraction,
|
|
59
|
+
_warn_unresolved_clusters,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _cp():
|
|
64
|
+
"""Lazily import cupy with a user-friendly error."""
|
|
65
|
+
try:
|
|
66
|
+
import cupy
|
|
67
|
+
|
|
68
|
+
return cupy
|
|
69
|
+
except ImportError as e:
|
|
70
|
+
msg = (
|
|
71
|
+
"The GPU tagging backend requires CuPy to be installed. "
|
|
72
|
+
"Install it with: pip install 'pycosmommf[gpu]'"
|
|
73
|
+
)
|
|
74
|
+
raise ImportError(msg) from e
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _to_cupy(x):
|
|
78
|
+
"""
|
|
79
|
+
Return ``x`` as a CuPy array, without a copy when it is already on the GPU.
|
|
80
|
+
|
|
81
|
+
NumPy arrays are uploaded. JAX GPU arrays are shared through DLPack (after
|
|
82
|
+
waiting for JAX's asynchronous dispatch to finish); JAX CPU arrays and
|
|
83
|
+
anything else array-like are converted through NumPy and uploaded.
|
|
84
|
+
"""
|
|
85
|
+
cp = _cp()
|
|
86
|
+
if isinstance(x, cp.ndarray):
|
|
87
|
+
return x
|
|
88
|
+
if isinstance(x, np.ndarray):
|
|
89
|
+
return cp.asarray(x)
|
|
90
|
+
if hasattr(x, "block_until_ready"):
|
|
91
|
+
x.block_until_ready()
|
|
92
|
+
if hasattr(x, "__dlpack__"):
|
|
93
|
+
with contextlib.suppress(TypeError): # raised for CPU-resident arrays
|
|
94
|
+
return cp.from_dlpack(x)
|
|
95
|
+
return cp.asarray(np.asarray(x))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@contextlib.contextmanager
|
|
99
|
+
def _stream_ordered_allocator():
|
|
100
|
+
"""
|
|
101
|
+
Allocate CuPy memory from CUDA's stream-ordered pool inside the block,
|
|
102
|
+
then release the pool's unused blocks. See the module docstring for why.
|
|
103
|
+
Falls back to CuPy's current allocator on devices without memory pools.
|
|
104
|
+
"""
|
|
105
|
+
cp = _cp()
|
|
106
|
+
runtime = cp.cuda.runtime
|
|
107
|
+
device = cp.cuda.Device()
|
|
108
|
+
if not runtime.deviceGetAttribute(
|
|
109
|
+
runtime.cudaDevAttrMemoryPoolsSupported, device.id
|
|
110
|
+
):
|
|
111
|
+
yield
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
# CuPy marks MemoryAsyncPool as experimental and warns on every use.
|
|
115
|
+
with warnings.catch_warnings():
|
|
116
|
+
warnings.filterwarnings(
|
|
117
|
+
"ignore",
|
|
118
|
+
message=".*MemoryAsyncPool is experimental",
|
|
119
|
+
category=FutureWarning,
|
|
120
|
+
)
|
|
121
|
+
pool = cp.cuda.MemoryAsyncPool()
|
|
122
|
+
try:
|
|
123
|
+
with cp.cuda.using_allocator(pool.malloc):
|
|
124
|
+
yield
|
|
125
|
+
finally:
|
|
126
|
+
device.synchronize()
|
|
127
|
+
pool.free_all_blocks()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _full_sum(kernel, *args):
|
|
131
|
+
"""
|
|
132
|
+
Reduce ``kernel`` over every element of its (3D) array arguments and
|
|
133
|
+
return the float32 result.
|
|
134
|
+
|
|
135
|
+
CuPy runs a reduction to a single scalar on one thread block, which is
|
|
136
|
+
~75x slower than reducing each x-plane first (one block per plane) and
|
|
137
|
+
then summing the per-plane partials.
|
|
138
|
+
"""
|
|
139
|
+
partials = kernel(*args, axis=tuple(range(1, args[0].ndim)))
|
|
140
|
+
return np.float32(partials.sum().get())
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@functools.cache
|
|
144
|
+
def _masked_mass_above_kernel():
|
|
145
|
+
"""float32 sum of ``rho`` over voxels with ``valid & (sig > thresh)``."""
|
|
146
|
+
cp = _cp()
|
|
147
|
+
return cp.ReductionKernel(
|
|
148
|
+
"float32 sig, float32 rho, bool valid, float32 thresh",
|
|
149
|
+
"float32 total",
|
|
150
|
+
"(valid && sig > thresh) ? rho : 0.0f",
|
|
151
|
+
"a + b",
|
|
152
|
+
"total = a",
|
|
153
|
+
"0",
|
|
154
|
+
"pycosmommf_masked_mass_above",
|
|
155
|
+
reduce_type="float",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@functools.cache
|
|
160
|
+
def _masked_mass_kernel():
|
|
161
|
+
"""float32 sum of ``rho`` over voxels where ``mask`` is set."""
|
|
162
|
+
cp = _cp()
|
|
163
|
+
return cp.ReductionKernel(
|
|
164
|
+
"bool mask, float32 rho",
|
|
165
|
+
"float32 total",
|
|
166
|
+
"mask ? rho : 0.0f",
|
|
167
|
+
"a + b",
|
|
168
|
+
"total = a",
|
|
169
|
+
"0",
|
|
170
|
+
"pycosmommf_masked_mass",
|
|
171
|
+
reduce_type="float",
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@functools.cache
|
|
176
|
+
def _clump_sums_kernel():
|
|
177
|
+
"""Atomically add each voxel's δ (float32) and a count (int32) to its clump."""
|
|
178
|
+
cp = _cp()
|
|
179
|
+
return cp.ElementwiseKernel(
|
|
180
|
+
"int32 label, float32 d",
|
|
181
|
+
"raw float32 sums, raw int32 counts",
|
|
182
|
+
"atomicAdd(&sums[label - 1], d); atomicAdd(&counts[label - 1], 1);",
|
|
183
|
+
"pycosmommf_clump_sums",
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@functools.cache
|
|
188
|
+
def _structure_filter_kernel():
|
|
189
|
+
"""uint8 structure type per voxel from the (mutually exclusive) cluster, filament and wall masks."""
|
|
190
|
+
cp = _cp()
|
|
191
|
+
return cp.ElementwiseKernel(
|
|
192
|
+
"bool c, bool f, bool w",
|
|
193
|
+
"uint8 s",
|
|
194
|
+
f"s = c ? {_CLUSTER} : (f ? {_FILAMENT} : (w ? {_WALL} : {_VOID}))",
|
|
195
|
+
"pycosmommf_structure_filter",
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _make_the_clusbool_cupy(delta, cluster_signature, overdensity_threshold):
|
|
200
|
+
"""
|
|
201
|
+
GPU version of ``tagging.make_the_clusbool``. ``delta`` is the δ field and
|
|
202
|
+
``cluster_signature`` the cluster column of the maximum signatures, both
|
|
203
|
+
float32 CuPy arrays. Returns ``(clusbool, S_th, signature_thresholds,
|
|
204
|
+
virialized_fractions)`` with ``clusbool`` as a CuPy array.
|
|
205
|
+
"""
|
|
206
|
+
cp = _cp()
|
|
207
|
+
from cupyx.scipy import ndimage
|
|
208
|
+
|
|
209
|
+
structure = np.ones((3, 3, 3), dtype=bool) # 26-connectivity
|
|
210
|
+
clump_sums = _clump_sums_kernel()
|
|
211
|
+
signature_thresholds = _cluster_signature_thresholds()
|
|
212
|
+
virialized_fractions = np.zeros_like(signature_thresholds)
|
|
213
|
+
flat_delta = delta.ravel()
|
|
214
|
+
|
|
215
|
+
for i, S_th in enumerate(signature_thresholds):
|
|
216
|
+
# Identify Clumps with S > S_th
|
|
217
|
+
_clusbool = cluster_signature > S_th
|
|
218
|
+
clumps, n_clumps = ndimage.label(_clusbool, structure=structure)
|
|
219
|
+
|
|
220
|
+
if n_clumps == 0:
|
|
221
|
+
virialized_fractions[i] = _virialized_fraction(0, 0)
|
|
222
|
+
continue
|
|
223
|
+
|
|
224
|
+
# Only foreground voxels: sending every background voxel through the
|
|
225
|
+
# atomics would serialise them on label 0 (~50x slower).
|
|
226
|
+
foreground = _clusbool.ravel()
|
|
227
|
+
sums = cp.zeros(n_clumps, dtype=cp.float32)
|
|
228
|
+
counts = cp.zeros(n_clumps, dtype=cp.int32)
|
|
229
|
+
clump_sums(clumps.ravel()[foreground], flat_delta[foreground], sums, counts)
|
|
230
|
+
mean_overdensity = sums / counts.astype(cp.float32)
|
|
231
|
+
virialized_fractions[i] = _virialized_fraction(
|
|
232
|
+
int(cp.count_nonzero(mean_overdensity > overdensity_threshold)), n_clumps
|
|
233
|
+
)
|
|
234
|
+
del _clusbool, clumps, foreground, sums, counts, mean_overdensity
|
|
235
|
+
|
|
236
|
+
S_th = _select_cluster_threshold(signature_thresholds, virialized_fractions)
|
|
237
|
+
if S_th is None:
|
|
238
|
+
_warn_unresolved_clusters(overdensity_threshold)
|
|
239
|
+
clusbool = cp.zeros(cluster_signature.shape, dtype=bool)
|
|
240
|
+
else:
|
|
241
|
+
clusbool = cluster_signature > S_th
|
|
242
|
+
|
|
243
|
+
return clusbool, S_th, signature_thresholds, virialized_fractions
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _mass_change_cupy(sig, rho, valid, Smin, Smax):
|
|
247
|
+
"""GPU version of ``tagging.calc_mass_change`` restricted to ``valid`` voxels."""
|
|
248
|
+
kernel = _masked_mass_above_kernel()
|
|
249
|
+
log10S = _log10S_grid(Smin, Smax)
|
|
250
|
+
thresholds = np.power(np.float32(10.0), log10S)
|
|
251
|
+
M = np.array(
|
|
252
|
+
[_full_sum(kernel, sig, rho, valid, t) for t in thresholds], dtype=np.float32
|
|
253
|
+
)
|
|
254
|
+
return _mass_change_curve(log10S, M)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def structure_bools_cupy(
|
|
258
|
+
density_cube,
|
|
259
|
+
max_sigs,
|
|
260
|
+
verbose_flag,
|
|
261
|
+
clusbool,
|
|
262
|
+
Smin,
|
|
263
|
+
Smax,
|
|
264
|
+
overdensity_threshold,
|
|
265
|
+
):
|
|
266
|
+
"""
|
|
267
|
+
CuPy implementation of ``calc_structure_bools()``. Returns the same dict
|
|
268
|
+
as ``tagging._structure_bools_cpu``, with the structure filter copied
|
|
269
|
+
back to NumPy.
|
|
270
|
+
"""
|
|
271
|
+
cp = _cp()
|
|
272
|
+
|
|
273
|
+
with _stream_ordered_allocator():
|
|
274
|
+
rho = _to_cupy(density_cube).astype(cp.float32, copy=False)
|
|
275
|
+
sigs = _to_cupy(max_sigs).astype(cp.float32, copy=False)
|
|
276
|
+
|
|
277
|
+
# Check if the input density_cube is actually δ
|
|
278
|
+
_check_not_overdensity(cp.mean(rho).get())
|
|
279
|
+
|
|
280
|
+
# Step 1. Cluster bool Filter
|
|
281
|
+
if clusbool is None:
|
|
282
|
+
delta = rho - cp.float32(1.0)
|
|
283
|
+
clusbool, cluster_thresh, S_clus, f_vir_clus = _make_the_clusbool_cupy(
|
|
284
|
+
delta, sigs[..., 0], overdensity_threshold
|
|
285
|
+
)
|
|
286
|
+
del delta
|
|
287
|
+
else:
|
|
288
|
+
clusbool = _to_cupy(clusbool).astype(bool, copy=False)
|
|
289
|
+
cluster_thresh = None
|
|
290
|
+
S_clus = None
|
|
291
|
+
f_vir_clus = None
|
|
292
|
+
|
|
293
|
+
# Step 2. Filament bool Filter (valid = not clusters)
|
|
294
|
+
not_clus = ~clusbool
|
|
295
|
+
filament_signature = sigs[..., 1]
|
|
296
|
+
S_fil, dM2_fil = _mass_change_cupy(
|
|
297
|
+
filament_signature, rho, not_clus, Smin, Smax
|
|
298
|
+
)
|
|
299
|
+
filament_thresh = S_fil[np.argmax(dM2_fil)]
|
|
300
|
+
filbool = (filament_signature > filament_thresh) & not_clus
|
|
301
|
+
|
|
302
|
+
# Step 3. Wall bool Filter (valid = not clusters or filaments)
|
|
303
|
+
wall_signature = sigs[..., 2]
|
|
304
|
+
wall_valid = not_clus & (filament_signature < filament_thresh)
|
|
305
|
+
del not_clus
|
|
306
|
+
S_wall, dM2_wall = _mass_change_cupy(
|
|
307
|
+
wall_signature, rho, wall_valid, Smin, Smax
|
|
308
|
+
)
|
|
309
|
+
del wall_valid
|
|
310
|
+
wall_thresh = S_wall[np.argmax(dM2_wall)]
|
|
311
|
+
wallbool = (wall_signature > wall_thresh) & ~(filbool | clusbool)
|
|
312
|
+
|
|
313
|
+
# Step 4. Voids are everything else; build the structure filter
|
|
314
|
+
structure_filter = _structure_filter_kernel()(clusbool, filbool, wallbool)
|
|
315
|
+
del clusbool, filbool, wallbool
|
|
316
|
+
|
|
317
|
+
result = {
|
|
318
|
+
"cluster_thresh": cluster_thresh,
|
|
319
|
+
"S_clus": S_clus,
|
|
320
|
+
"f_vir_clus": f_vir_clus,
|
|
321
|
+
"S_fil": S_fil,
|
|
322
|
+
"dM2_fil": dM2_fil,
|
|
323
|
+
"filament_thresh": filament_thresh,
|
|
324
|
+
"S_wall": S_wall,
|
|
325
|
+
"dM2_wall": dM2_wall,
|
|
326
|
+
"wall_thresh": wall_thresh,
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if verbose_flag:
|
|
330
|
+
masks = [structure_filter == k for k in (_CLUSTER, _FILAMENT, _WALL, _VOID)]
|
|
331
|
+
masked_mass = _masked_mass_kernel()
|
|
332
|
+
total_mass = np.float32(cp.sum(rho).get())
|
|
333
|
+
result["volume_fractions"] = [
|
|
334
|
+
np.float32(int(cp.count_nonzero(b))) / np.float32(rho.size)
|
|
335
|
+
for b in masks
|
|
336
|
+
]
|
|
337
|
+
result["mass_fractions"] = [
|
|
338
|
+
_full_sum(masked_mass, b, rho) / total_mass for b in masks
|
|
339
|
+
]
|
|
340
|
+
|
|
341
|
+
result["structure_filter"] = cp.asnumpy(structure_filter)
|
|
342
|
+
|
|
343
|
+
return result
|