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.
@@ -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