pixpoint 8.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.
Files changed (50) hide show
  1. pixpoint/__init__.py +86 -0
  2. pixpoint/_numba.py +23 -0
  3. pixpoint/backend/__init__.py +22 -0
  4. pixpoint/backend/aperture.py +387 -0
  5. pixpoint/backend/combinations.py +177 -0
  6. pixpoint/backend/core.py +641 -0
  7. pixpoint/backend/degrade.py +139 -0
  8. pixpoint/backend/density.py +687 -0
  9. pixpoint/backend/moments.py +1262 -0
  10. pixpoint/backend/raw.py +407 -0
  11. pixpoint/backend/shear.py +532 -0
  12. pixpoint/backend/three_by_two.py +483 -0
  13. pixpoint/backend/threepoint.py +236 -0
  14. pixpoint/compute_context.py +176 -0
  15. pixpoint/correlation_helpers.py +784 -0
  16. pixpoint/correlations.py +6577 -0
  17. pixpoint/cuda/aperture_moments.cuh +68 -0
  18. pixpoint/cuda/aperture_tomo.cu +436 -0
  19. pixpoint/cuda/common.cuh +123 -0
  20. pixpoint/cuda/degrade_rows.cu +199 -0
  21. pixpoint/cuda/density_density_tomo_vectorized.cu +156 -0
  22. pixpoint/cuda/density_shear_tomo_vectorized.cu +255 -0
  23. pixpoint/cuda/moment_rows.cu +144 -0
  24. pixpoint/cuda/pair_tiles.cuh +989 -0
  25. pixpoint/cuda/threepoint_multipoles.cu +168 -0
  26. pixpoint/cuda/tomo_fused_3x2pt.cu +376 -0
  27. pixpoint/cuda/tomo_tiled_3x2pt.cu +161 -0
  28. pixpoint/cuda/tomo_vectorized_xipm.cu +277 -0
  29. pixpoint/global_2pcf.py +1118 -0
  30. pixpoint/global_3pcf.py +970 -0
  31. pixpoint/global_geometry.py +440 -0
  32. pixpoint/io_handler.py +607 -0
  33. pixpoint/moments.py +411 -0
  34. pixpoint/multi_device.py +334 -0
  35. pixpoint/onepoint.py +838 -0
  36. pixpoint/packing.py +317 -0
  37. pixpoint/pair_finder.py +210 -0
  38. pixpoint/pair_geometry.py +878 -0
  39. pixpoint/pipeline.py +288 -0
  40. pixpoint/py.typed +0 -0
  41. pixpoint/result_writer.py +481 -0
  42. pixpoint/results.py +445 -0
  43. pixpoint/treecode.py +416 -0
  44. pixpoint/utils.py +238 -0
  45. pixpoint/zeta_writer.py +222 -0
  46. pixpoint-8.0.0.dist-info/METADATA +169 -0
  47. pixpoint-8.0.0.dist-info/RECORD +50 -0
  48. pixpoint-8.0.0.dist-info/WHEEL +5 -0
  49. pixpoint-8.0.0.dist-info/licenses/LICENSE +21 -0
  50. pixpoint-8.0.0.dist-info/top_level.txt +1 -0
pixpoint/__init__.py ADDED
@@ -0,0 +1,86 @@
1
+ """
2
+ PixPoint: GPU/CPU measurement of integrated, two- and three-point correlations.
3
+
4
+ Three estimators on HEALPix maps, named after the statistic they deliver:
5
+ :class:`Integrated3PCF` (per-patch aperture masses and 2PCFs, the i3PCF
6
+ ingredients), :class:`Global2PCF` (footprint-wide 2PCFs),
7
+ :class:`Global3PCF` (footprint-wide 3PCF multipoles) and :class:`OnePoint`
8
+ (PDFs and moments of aperture fields: ``g`` for the aperture mass, ``k`` for a
9
+ filtered scalar field). Methods are TreeCorr
10
+ field codes (``g`` = shear, ``k`` = scalar field): ``gg``, ``kk``, ``kg``,
11
+ ``three_by_two_pt``, ``ggg``, ``kkk``, ``kgg``, ``kkg``. Every call returns a
12
+ labelled result (:class:`IntegratedResult`, :class:`TwoPointResult`,
13
+ :class:`ThreePointResult`, :class:`OnePointResult`).
14
+ """
15
+
16
+ from .correlation_helpers import (
17
+ Q_crittenden,
18
+ Q_schneider,
19
+ U_crittenden,
20
+ U_schneider,
21
+ U_tophat,
22
+ calculate_all_zetas,
23
+ zeta_a_g,
24
+ zeta_a_minus,
25
+ zeta_a_plus,
26
+ zeta_a_t,
27
+ zeta_g_g,
28
+ zeta_g_minus,
29
+ zeta_g_plus,
30
+ zeta_g_t,
31
+ )
32
+ from .correlations import Integrated3PCF
33
+ from .global_2pcf import Global2PCF
34
+ from .global_3pcf import Global3PCF
35
+ from .onepoint import OnePoint
36
+ from .pipeline import MapFileLoader, MapLoader
37
+ from .result_writer import OnePointWriter, ResultWriter, result_arrays
38
+ from .results import (
39
+ Component,
40
+ IntegratedResult,
41
+ OnePointResult,
42
+ ThreePointResult,
43
+ TwoPointResult,
44
+ )
45
+ from .treecode import DEFAULT_RESOLUTION_FACTOR
46
+ from .utils import pixel2RaDec, select_patch_centers
47
+ from .zeta_writer import ZetaWriter
48
+
49
+ __version__ = "8.0.0"
50
+ __author__ = "David Gebauer"
51
+ __email__ = "git@gebauer.ai"
52
+
53
+ __all__ = [
54
+ "Component",
55
+ "Global2PCF",
56
+ "Global3PCF",
57
+ "Integrated3PCF",
58
+ "IntegratedResult",
59
+ "OnePoint",
60
+ "OnePointResult",
61
+ "OnePointWriter",
62
+ "ResultWriter",
63
+ "result_arrays",
64
+ "ThreePointResult",
65
+ "TwoPointResult",
66
+ "DEFAULT_RESOLUTION_FACTOR",
67
+ "MapLoader",
68
+ "MapFileLoader",
69
+ "Q_crittenden",
70
+ "Q_schneider",
71
+ "U_crittenden",
72
+ "U_schneider",
73
+ "U_tophat",
74
+ "calculate_all_zetas",
75
+ "zeta_g_plus",
76
+ "zeta_g_minus",
77
+ "zeta_a_plus",
78
+ "zeta_a_minus",
79
+ "zeta_g_g",
80
+ "zeta_a_g",
81
+ "zeta_g_t",
82
+ "zeta_a_t",
83
+ "pixel2RaDec",
84
+ "select_patch_centers",
85
+ "ZetaWriter",
86
+ ]
pixpoint/_numba.py ADDED
@@ -0,0 +1,23 @@
1
+ """Numba entry points with types mypy can check.
2
+
3
+ ``numba.prange`` is typed as a class without ``__iter__``; to a type checker
4
+ it is ``range`` (which is also what it is with ``NUMBA_DISABLE_JIT=1``). At
5
+ run time the jitted functions see the real ``numba.prange``.
6
+ """
7
+
8
+ from typing import TYPE_CHECKING, Any, Callable
9
+
10
+ import numba
11
+ from numba import njit
12
+
13
+ if TYPE_CHECKING:
14
+ prange = range
15
+ else:
16
+ from numba import prange
17
+
18
+ #: ``numba.get_num_threads``, typed (numba < 0.68 leaves it untyped; going
19
+ #: through ``Any`` is correct for both).
20
+ _numba: Any = numba
21
+ get_num_threads: Callable[[], int] = _numba.get_num_threads
22
+
23
+ __all__ = ["get_num_threads", "njit", "prange"]
@@ -0,0 +1,22 @@
1
+ """
2
+ Backend abstraction layer for CPU (NumPy/Numba) and GPU (CuPy/CUDA) execution.
3
+
4
+ All correlation measurement kernels exist in two forms:
5
+ - **CPU**: Numba @njit functions with parallel=True for multi-core execution
6
+ - **GPU**: CuPy ElementwiseKernels for simple per-pair operations, and
7
+ compiled CUDA kernels (.cu files in cuda/) for fused/vectorised
8
+ tomographic operations, launched through :class:`raw.TypedKernel`
9
+ (every launch checked against the signature parsed from the source)
10
+
11
+ One module per kernel family, CPU kernel and GPU wrapper side by side:
12
+ :mod:`.aperture` (M_ap, M_g), :mod:`.density` (ξ_g, ξ_t), :mod:`.shear`
13
+ (ξ±), :mod:`.three_by_two` (the fused 3x2pt path), :mod:`.degrade` (treecode
14
+ rows), :mod:`.threepoint` (step 1 of Global3PCF); :mod:`.combinations` holds
15
+ the tomographic layouts of the tiled kernels, :mod:`.raw` compiling and
16
+ launching, :mod:`.core` the :class:`Backend` that holds the active kernel set
17
+ and provides to_device()/to_numpy() for data movement.
18
+ """
19
+
20
+ from .core import Backend, get_backend, safe_divide
21
+
22
+ __all__ = ["Backend", "get_backend", "safe_divide"]
@@ -0,0 +1,387 @@
1
+ """Aperture statistics M_ap (shear) and M_g (density) per patch.
2
+
3
+ CPU: Numba kernels. GPU: per-pixel ``ElementwiseKernel`` contributions and
4
+ the block-reduced tomographic kernels of ``cuda/aperture_tomo.cu``.
5
+ """
6
+
7
+ from typing import Any, Optional, Tuple
8
+
9
+ import numpy as np
10
+
11
+ from .._numba import njit, prange
12
+ from .raw import _CUPY_FASTMATH_OPTIONS, KernelFile, _has_raw_cuda_compiler, c_real
13
+
14
+ # Beyond this many tomographic bins the fused aperture kernels (one block per
15
+ # patch, the bins looped inside it) hold too many per-thread accumulators --
16
+ # 2*NZ each -- and the per-(patch, bin) kernel is launched instead.
17
+ _MAX_FUSED_APERTURE_BINS = 16
18
+ # Map arguments the aperture kernels address through explicit strides
19
+ # (planar, strided and AoS views are all passed without a copy).
20
+ _STRIDED = ("g1", "g2", "weights", "values")
21
+
22
+
23
+ # ── Aperture statistics kernels ──────────────────────────────────────────
24
+ # Compute the aperture-filtered field for each sky patch:
25
+ # M = A_patch · Σ(w · field · Q(θ)) / Σ(w)
26
+ # where Q(θ) is the compensated filter function.
27
+
28
+
29
+ @njit(fastmath=True, parallel=True, cache=True)
30
+ def _cpu_aperture_density_kernel(
31
+ Q_inds: np.ndarray,
32
+ Q_val: np.ndarray,
33
+ Q_offsets: np.ndarray,
34
+ map_values: np.ndarray,
35
+ weights: np.ndarray,
36
+ Q_patch_area: np.ndarray,
37
+ out_aperture: np.ndarray,
38
+ ) -> None:
39
+ """Galaxy mean density M_g within an aperture: weighted δ_g convolved
40
+ with the compensated filter Q(θ)."""
41
+ n_patches = Q_offsets.shape[0] - 1
42
+ # float64 explicitly, not ``x[0] * 0.0``. That expression types as
43
+ # float64 under numba (python-float promotion) but as float32 under
44
+ # NUMBA_DISABLE_JIT=1, which pytest-env forces -- so the suite was
45
+ # measuring a float32 accumulation of a kernel that ships accumulating in
46
+ # float64. Pinning it keeps the shipped numbers and makes the tests
47
+ # exercise them. (It is deliberately not ``acc_dtype``: these four
48
+ # kernels reduce over a whole aperture disc or angular bin, where a
49
+ # float32 accumulator loses precision for nothing.)
50
+ zero = np.float64(0)
51
+
52
+ for patch_idx in prange(n_patches):
53
+ start = Q_offsets[patch_idx]
54
+ stop = Q_offsets[patch_idx + 1]
55
+ sum_w = zero
56
+ sum_wdelta_q = zero
57
+ for i in range(start, stop):
58
+ pix_idx = Q_inds[i]
59
+ weight = weights[pix_idx]
60
+ sum_w += weight
61
+ sum_wdelta_q += weight * map_values[pix_idx] * Q_val[i]
62
+ # 0, not NaN, for an empty aperture -- like every other M_g path
63
+ if sum_w == 0:
64
+ out_aperture[patch_idx] = 0.0
65
+ else:
66
+ out_aperture[patch_idx] = Q_patch_area[patch_idx] * sum_wdelta_q / sum_w
67
+
68
+
69
+ @njit(fastmath=True, parallel=True, cache=True)
70
+ def _cpu_aperture_shear_kernel(
71
+ Q_inds: np.ndarray,
72
+ Q_cos: np.ndarray,
73
+ Q_sin: np.ndarray,
74
+ Q_val: np.ndarray,
75
+ Q_offsets: np.ndarray,
76
+ g1: np.ndarray,
77
+ g2: np.ndarray,
78
+ weights: np.ndarray,
79
+ Q_patch_area: np.ndarray,
80
+ out_aperture: np.ndarray,
81
+ ) -> None:
82
+ """Aperture mass M_ap: tangential shear γ_t convolved with Q(θ).
83
+
84
+ γ_t = -γ₁·cos(2φ) - γ₂·sin(2φ) is the tangential shear component
85
+ relative to the patch centre.
86
+ """
87
+ n_patches = Q_offsets.shape[0] - 1
88
+ # float64 explicitly, not ``x[0] * 0.0``. That expression types as
89
+ # float64 under numba (python-float promotion) but as float32 under
90
+ # NUMBA_DISABLE_JIT=1, which pytest-env forces -- so the suite was
91
+ # measuring a float32 accumulation of a kernel that ships accumulating in
92
+ # float64. Pinning it keeps the shipped numbers and makes the tests
93
+ # exercise them. (It is deliberately not ``acc_dtype``: these four
94
+ # kernels reduce over a whole aperture disc or angular bin, where a
95
+ # float32 accumulator loses precision for nothing.)
96
+ zero = np.float64(0)
97
+
98
+ for patch_idx in prange(n_patches):
99
+ start = Q_offsets[patch_idx]
100
+ stop = Q_offsets[patch_idx + 1]
101
+ sum_w = zero
102
+ sum_wgt_q = zero
103
+ for i in range(start, stop):
104
+ pix_idx = Q_inds[i]
105
+ weight = weights[pix_idx]
106
+ # Tangential shear projection
107
+ gt = -g1[pix_idx] * Q_cos[i] - g2[pix_idx] * Q_sin[i]
108
+ sum_w += weight
109
+ sum_wgt_q += weight * gt * Q_val[i]
110
+ out_aperture[patch_idx] = Q_patch_area[patch_idx] * sum_wgt_q / sum_w
111
+
112
+
113
+ # ── CuPy ElementwiseKernel builders (GPU per-element operations) ──────────
114
+
115
+
116
+ def _build_cupy_aperture_density_kernel(module: Any) -> Any:
117
+ """GPU kernel: per-pixel contribution to galaxy mean density M_g.
118
+
119
+ The filter geometry arrays (type letter ``Q``) may be narrower than the
120
+ map type ``T``; the ``(T)`` promotion at use is exact for float32 →
121
+ float64.
122
+ """
123
+ return module.ElementwiseKernel(
124
+ "raw I Q_inds, raw Q Q_val, raw T map_values, raw T weights",
125
+ "T out_num, T out_den",
126
+ """
127
+ const I idx = Q_inds[i];
128
+ const T w = weights[idx];
129
+ out_num = w * map_values[idx] * (T)Q_val[i];
130
+ out_den = w;
131
+ """,
132
+ "gpu_aperture_density_kernel",
133
+ options=_CUPY_FASTMATH_OPTIONS,
134
+ )
135
+
136
+
137
+ def _build_cupy_aperture_shear_kernel(module: Any) -> Any:
138
+ """GPU kernel: per-pixel contribution to aperture mass M_ap via γ_t·Q(θ).
139
+
140
+ The filter geometry arrays (type letter ``Q``) may be narrower than the
141
+ map type ``T``; the ``(T)`` promotion at use is exact for float32 →
142
+ float64.
143
+ """
144
+ return module.ElementwiseKernel(
145
+ "raw I Q_inds, raw Q Q_cos, raw Q Q_sin, raw Q Q_val,"
146
+ " raw T g1, raw T g2, raw T weights",
147
+ "T out_num, T out_den",
148
+ """
149
+ const I idx = Q_inds[i];
150
+ const T w = weights[idx];
151
+ const T gt = -g1[idx] * (T)Q_cos[i] - g2[idx] * (T)Q_sin[i];
152
+ out_num = w * gt * (T)Q_val[i];
153
+ out_den = w;
154
+ """,
155
+ "gpu_aperture_shear_kernel",
156
+ options=_CUPY_FASTMATH_OPTIONS,
157
+ )
158
+
159
+
160
+ # ── Block-reduced aperture RawKernels (all tomo bins in one launch) ───────
161
+ # Replace the per-pixel ElementwiseKernel + add.reduceat path with a single
162
+ # launch over (patch, tomo bin) blocks; the wrappers fall back (return
163
+ # False) when no raw compiler is available so callers can use the legacy
164
+ # ElementwiseKernel path.
165
+
166
+
167
+ def _aperture_tomo_prepare_planar(module: Any, arr: Any) -> Tuple[Any, int, int]:
168
+ """Return (array, bin stride, row stride) in elements for a 2D view.
169
+
170
+ The kernel takes both strides, so nothing is ever copied: planar
171
+ ``(nz, npix)`` arrays, strided views such as ``shear[:, 0]`` of an
172
+ ``(nz, 2, npix)`` array, and the transposed AoS buffers
173
+ ``shear_aos[:, :, 0].T`` of an ``(npix, nz, 2)`` array all work.
174
+ """
175
+ del module # no copy is needed for any layout
176
+ return (
177
+ arr,
178
+ int(arr.strides[0] // arr.itemsize),
179
+ int(arr.strides[1] // arr.itemsize),
180
+ )
181
+
182
+
183
+ # (runtime id, device) -> (runtime, SM count). The runtime object is kept
184
+ # alive by the value so its id cannot be reused by a different one.
185
+ _SM_COUNT_CACHE: dict[Tuple[int, int], Tuple[Any, Optional[int]]] = {}
186
+
187
+
188
+ def _multiprocessor_count(module: Any) -> Optional[int]:
189
+ """SM count of the active device, or None when it cannot be queried.
190
+
191
+ Only used to size a launch grid, so a stand-in module without
192
+ ``cuda.runtime`` (the emulated cupy of the tests) simply imposes no
193
+ constraint.
194
+ """
195
+ runtime = getattr(getattr(module, "cuda", None), "runtime", None)
196
+ if runtime is None:
197
+ return None
198
+ try:
199
+ device = int(runtime.getDevice())
200
+ except Exception: # pragma: no cover - no device behind the module
201
+ return None
202
+ key = (id(runtime), device)
203
+ if key not in _SM_COUNT_CACHE:
204
+ try:
205
+ props = runtime.getDeviceProperties(device)
206
+ count: Optional[int] = int(props["multiProcessorCount"])
207
+ except Exception: # pragma: no cover - older runtime without the field
208
+ count = None
209
+ _SM_COUNT_CACHE[key] = (runtime, count)
210
+ return _SM_COUNT_CACHE[key][1]
211
+
212
+
213
+ def _use_fused_aperture(module: Any, ntomo: int, npatches: int) -> bool:
214
+ """Whether to launch the one-block-per-patch aperture kernel.
215
+
216
+ It reads the aperture disc geometry once per pixel instead of once per
217
+ tomographic bin: 1.72x on the kernel at nside 512 and 1.83x measured in
218
+ situ inside ``gg`` (the result is bitwise identical
219
+ either way). Two things take it back to the
220
+ per-(patch, bin) kernel: a bin set wide enough for the per-thread
221
+ accumulators to spill, and a patch set small enough that one block per
222
+ patch underfills the device -- the fused grid is ``ntomo`` times
223
+ smaller, which is the point of it.
224
+ """
225
+ if ntomo <= 0 or ntomo > _MAX_FUSED_APERTURE_BINS:
226
+ return False
227
+ n_sm = _multiprocessor_count(module)
228
+ if n_sm is not None and npatches < 2 * n_sm:
229
+ return False
230
+ return True
231
+
232
+
233
+ def _build_cupy_aperture_tomo_shear_kernel(module: Any) -> Any:
234
+ """Builder for the GPU block-reduced tomographic aperture-mass kernel.
235
+
236
+ Two kernels behind one launch contract (see ``aperture_tomo.cu``): the
237
+ fused one-block-per-patch kernel is the default and reads the disc
238
+ geometry once per pixel instead of once per tomographic bin; the
239
+ per-(patch, bin) kernel is the fallback for wide bin sets and small
240
+ patch counts. The two are bitwise identical, so which one ran is never
241
+ observable in the result. ``ntomo`` is a template argument of the
242
+ fused kernel and therefore part of the cache key; it is small and
243
+ stable, so compiling on demand per value is fine.
244
+ """
245
+ kernels = KernelFile(
246
+ module, "aperture_tomo.cu", "Aperture-shear tomo", strided=_STRIDED
247
+ )
248
+
249
+ def _cupy_aperture_tomo_shear_kernel(
250
+ g1: Any,
251
+ g2: Any,
252
+ weights: Any,
253
+ q_inds: Any,
254
+ q_cos: Any,
255
+ q_sin: Any,
256
+ q_val: Any,
257
+ q_offsets: Any,
258
+ q_patch_area: Any,
259
+ out_num: Any,
260
+ out_den: Any,
261
+ ) -> bool:
262
+ if not _has_raw_cuda_compiler(module):
263
+ return False
264
+ if g1.ndim != 2 or g2.ndim != 2 or weights.ndim != 2:
265
+ return False
266
+
267
+ targs: Tuple[Any, ...] = (c_real(weights.dtype), c_real(q_cos.dtype))
268
+ npatches = int(q_offsets.shape[0] - 1)
269
+ ntomo = int(g1.shape[0])
270
+ fused = _use_fused_aperture(module, ntomo, npatches)
271
+ raw_kernel = (
272
+ kernels("gpu_aperture_shear_tomo_fused", targs + (ntomo,))
273
+ if fused
274
+ else kernels("gpu_aperture_shear_tomo", targs)
275
+ )
276
+ if raw_kernel is None:
277
+ return False
278
+
279
+ g1, g1_stride, g1_elem = _aperture_tomo_prepare_planar(module, g1)
280
+ g2, g2_stride, g2_elem = _aperture_tomo_prepare_planar(module, g2)
281
+ if (g1_stride, g1_elem) != (g2_stride, g2_elem):
282
+ g1 = module.ascontiguousarray(g1)
283
+ g2 = module.ascontiguousarray(g2)
284
+ g1_stride = g2_stride = int(g1.shape[1])
285
+ g1_elem = g2_elem = 1
286
+ weights, w_stride, w_elem = _aperture_tomo_prepare_planar(module, weights)
287
+
288
+ blocks = (
289
+ (max(1, npatches), 1, 1) if fused else (max(1, npatches), max(1, ntomo), 1)
290
+ )
291
+ # The fused kernel knows NZ at compile time and takes no `ntomo`.
292
+ tail: Tuple[int, ...] = (npatches,) if fused else (npatches, ntomo)
293
+ raw_kernel(
294
+ blocks,
295
+ (256,),
296
+ (
297
+ g1,
298
+ g2,
299
+ g1_stride,
300
+ g1_elem,
301
+ weights,
302
+ w_stride,
303
+ w_elem,
304
+ q_inds,
305
+ q_cos,
306
+ q_sin,
307
+ q_val,
308
+ q_offsets,
309
+ q_patch_area,
310
+ out_num,
311
+ out_den,
312
+ *tail,
313
+ ),
314
+ )
315
+ return True
316
+
317
+ return _cupy_aperture_tomo_shear_kernel
318
+
319
+
320
+ def _build_cupy_aperture_tomo_density_kernel(module: Any) -> Any:
321
+ """Builder for the GPU block-reduced tomographic aperture-density kernel.
322
+
323
+ Same two-kernel contract as the aperture-mass builder above: the fused
324
+ one-block-per-patch kernel by default, the per-(patch, bin) kernel as
325
+ the fallback, bitwise identical results either way.
326
+ """
327
+ kernels = KernelFile(
328
+ module, "aperture_tomo.cu", "Aperture-density tomo", strided=_STRIDED
329
+ )
330
+
331
+ def _cupy_aperture_tomo_density_kernel(
332
+ values: Any,
333
+ weights: Any,
334
+ q_inds: Any,
335
+ q_val: Any,
336
+ q_offsets: Any,
337
+ q_patch_area: Any,
338
+ out_num: Any,
339
+ out_den: Any,
340
+ ) -> bool:
341
+ if not _has_raw_cuda_compiler(module):
342
+ return False
343
+ if values.ndim != 2 or weights.ndim != 2:
344
+ return False
345
+
346
+ targs: Tuple[Any, ...] = (c_real(weights.dtype), c_real(q_val.dtype))
347
+ npatches = int(q_offsets.shape[0] - 1)
348
+ ntomo = int(values.shape[0])
349
+ fused = _use_fused_aperture(module, ntomo, npatches)
350
+ raw_kernel = (
351
+ kernels("gpu_aperture_density_tomo_fused", targs + (ntomo,))
352
+ if fused
353
+ else kernels("gpu_aperture_density_tomo", targs)
354
+ )
355
+ if raw_kernel is None:
356
+ return False
357
+
358
+ values, v_stride, v_elem = _aperture_tomo_prepare_planar(module, values)
359
+ weights, w_stride, w_elem = _aperture_tomo_prepare_planar(module, weights)
360
+
361
+ blocks = (
362
+ (max(1, npatches), 1, 1) if fused else (max(1, npatches), max(1, ntomo), 1)
363
+ )
364
+ # The fused kernel knows NZ at compile time and takes no `ntomo`.
365
+ tail: Tuple[int, ...] = (npatches,) if fused else (npatches, ntomo)
366
+ raw_kernel(
367
+ blocks,
368
+ (256,),
369
+ (
370
+ values,
371
+ v_stride,
372
+ v_elem,
373
+ weights,
374
+ w_stride,
375
+ w_elem,
376
+ q_inds,
377
+ q_val,
378
+ q_offsets,
379
+ q_patch_area,
380
+ out_num,
381
+ out_den,
382
+ *tail,
383
+ ),
384
+ )
385
+ return True
386
+
387
+ return _cupy_aperture_tomo_density_kernel
@@ -0,0 +1,177 @@
1
+ """Tomographic combination layouts of the combination-tiled pair kernels.
2
+
3
+ A tiled kernel produces a *canonical* set of combinations (the row-major
4
+ upper triangle of one bin set, or the full product of two); a requested list
5
+ is classified against it once and, when it is a subset, gathered from a
6
+ canonical temporary.
7
+ """
8
+
9
+ from typing import Any, Callable, Optional, Tuple, cast
10
+
11
+ import numpy as np
12
+
13
+ from ..utils import live_object_serial
14
+ from .raw import _KERNEL_CACHE_MISS
15
+
16
+ _MAX_VECTOR_TOMO_BINS = 64
17
+ # Combination-tiled pair kernels keep every accumulator in a register; the
18
+ # tiles are used only while the per-thread accumulator count (numerators +
19
+ # weight sums over all combinations) stays within this budget. Beyond it
20
+ # the per-(bin, row) kernels are launched instead (unpacked geometry only).
21
+ _MAX_TILED_ACCUMULATORS = 64
22
+ # Budget of the single-pass 3x2pt tile (xi+- + xi_g + xi_t accumulators).
23
+ # Measured on an A100 up to 120 (4 source x 6 lens bins, all combinations),
24
+ # where it is still ahead of three separate tiles; untested beyond.
25
+ _MAX_TILED_3X2PT_ACCUMULATORS = 120
26
+
27
+
28
+ class _CombinationLayout:
29
+ """How a requested list of tomographic combinations maps onto the
30
+ canonical layout generated by the combination-tiled kernels."""
31
+
32
+ __slots__ = ("mode", "canonical_ncomb", "rows")
33
+
34
+ def __init__(
35
+ self, mode: str, canonical_ncomb: int, rows: Optional[np.ndarray]
36
+ ) -> None:
37
+ self.mode = mode # "triangle" | "auto" | "cartesian"
38
+ self.canonical_ncomb = canonical_ncomb # rows the tile produces
39
+ self.rows = rows # canonical row per request, None = identity
40
+
41
+
42
+ class TiledWrapper:
43
+ """A pair-kernel launch wrapper whose combination-tiled kernel can be
44
+ switched off: ``tiled = False`` forces the per-(bin, row) fallback
45
+ (tests and benchmarks compare the two)."""
46
+
47
+ def __init__(self, launch: Callable[..., bool]) -> None:
48
+ self.tiled = True
49
+ self._launch = launch
50
+
51
+ def __call__(self, *args: Any) -> bool:
52
+ return self._launch(*args)
53
+
54
+
55
+ class SinglePassWrapper:
56
+ """The launch wrapper of the single-pass 3x2pt tile. ``mode``:
57
+ ``"auto"`` (within ``max_accumulators``), ``"single"`` (always, for
58
+ benchmarks) or ``"off"``; it returns which of (ξ±, ξ_g, ξ_t) it computed."""
59
+
60
+ def __init__(
61
+ self, launch: Callable[..., Tuple[bool, bool, bool]], max_accumulators: int
62
+ ) -> None:
63
+ self.mode = "auto"
64
+ self.max_accumulators = max_accumulators
65
+ self._launch = launch
66
+
67
+ def __call__(self, *args: Any) -> Tuple[bool, bool, bool]:
68
+ return self._launch(*args)
69
+
70
+
71
+ _COMBINATION_LAYOUT_CACHE: dict[
72
+ tuple[int, int, str, int, int], Optional[_CombinationLayout]
73
+ ] = {}
74
+
75
+
76
+ def _combination_layout(
77
+ comb_i: Any, comb_j: Any, kind: str, n1: int, n2: int = 0
78
+ ) -> Optional[_CombinationLayout]:
79
+ """Classify ``(comb_i, comb_j)`` against the tiled kernels' layouts.
80
+
81
+ ``kind="triangle"``: n1 bins, canonical = row-major upper triangle
82
+ (a request (i, j) with i > j is the same symmetric combination as
83
+ (j, i)); a request consisting of exactly the auto combinations gets
84
+ ``mode="auto"`` (its own, smaller tile). ``kind="cartesian"``:
85
+ canonical = row-major n1 x n2 product. ``rows`` is None when the
86
+ request *is* the canonical layout, else the canonical row of every
87
+ requested combination. Returns None for out-of-range bins.
88
+
89
+ The (tiny) device arrays are compared once per array object and the
90
+ result cached by identity: the orchestrator caches and reuses them.
91
+
92
+ By ``live_object_serial`` and not ``id()``. The orchestrator caches the
93
+ *canonical* combination arrays, but an explicit ``ggl_bin_combinations``
94
+ selection builds fresh ones on every call and drops them, so a second
95
+ selection can land on the recycled addresses of the first -- and the
96
+ layout carries ``rows``, the canonical row of every requested
97
+ combination, so a stale hit scatters the results into the previous
98
+ selection's rows. The collision needs *both* ids to recycle at once and
99
+ CPython's LIFO free lists tend to swap rather than match them (it did not
100
+ fire in 4000 alternating calls here), but it is allocator luck, not a
101
+ guarantee.
102
+ """
103
+ key = (
104
+ live_object_serial(comb_i),
105
+ live_object_serial(comb_j),
106
+ kind,
107
+ int(n1),
108
+ int(n2),
109
+ )
110
+ cached = _COMBINATION_LAYOUT_CACHE.get(key, _KERNEL_CACHE_MISS)
111
+ if cached is not _KERNEL_CACHE_MISS:
112
+ return cast(Optional[_CombinationLayout], cached)
113
+
114
+ def to_host(a: Any) -> np.ndarray:
115
+ return np.asarray(a.get() if hasattr(a, "get") else a).astype(np.int64)
116
+
117
+ ci, cj = to_host(comb_i), to_host(comb_j)
118
+ layout: Optional[_CombinationLayout]
119
+ if kind == "triangle":
120
+ if (
121
+ ci.size == 0
122
+ or ci.min() < 0
123
+ or cj.min() < 0
124
+ or ci.max() >= n1
125
+ or cj.max() >= n1
126
+ ):
127
+ layout = None
128
+ else:
129
+ lo, hi = np.minimum(ci, cj), np.maximum(ci, cj)
130
+ rows = lo * n1 - (lo * (lo - 1)) // 2 + (hi - lo)
131
+ canonical = (n1 * (n1 + 1)) // 2
132
+ identity = rows.size == canonical and np.array_equal(
133
+ rows, np.arange(canonical)
134
+ )
135
+ if (
136
+ not identity
137
+ and ci.size == n1
138
+ and np.array_equal(ci, np.arange(n1))
139
+ and np.array_equal(cj, ci)
140
+ ):
141
+ layout = _CombinationLayout("auto", int(n1), None)
142
+ else:
143
+ layout = _CombinationLayout(
144
+ "triangle", int(canonical), None if identity else rows
145
+ )
146
+ elif kind == "cartesian":
147
+ if (
148
+ ci.size == 0
149
+ or ci.min() < 0
150
+ or cj.min() < 0
151
+ or ci.max() >= n1
152
+ or cj.max() >= n2
153
+ ):
154
+ layout = None
155
+ else:
156
+ rows = ci * n2 + cj
157
+ canonical = int(n1 * n2)
158
+ identity = rows.size == canonical and np.array_equal(
159
+ rows, np.arange(canonical)
160
+ )
161
+ layout = _CombinationLayout(
162
+ "cartesian", canonical, None if identity else rows
163
+ )
164
+ else:
165
+ raise ValueError(f"unknown combination layout kind {kind!r}")
166
+ if len(_COMBINATION_LAYOUT_CACHE) > 64:
167
+ _COMBINATION_LAYOUT_CACHE.clear()
168
+ _COMBINATION_LAYOUT_CACHE[key] = layout
169
+ return layout
170
+
171
+
172
+ def _scatter_canonical_rows(
173
+ module: Any, layout: _CombinationLayout, tmp: Any, out: Any
174
+ ) -> None:
175
+ """out[r] = tmp[layout.rows[r]] (tmp holds the canonical rows)."""
176
+ rows = module.asarray(layout.rows)
177
+ out[...] = tmp[rows]