Skip to content

Commit ec5da58

Browse files
ax3lclaude
andauthored
Add amr.xp; factor the per-dimension module setup into a helper (#609)
Two commits: the feature, then the deduplication it motivated. ## 1. `amr.xp`: the array namespace matching the build The `to_xp()` methods return NumPy, CuPy or dpnp arrays depending on how pyAMReX was built, but there is no way to reach the *array library itself*. CPU/GPU agnostic code regularly needs it for calls that are not methods on an array: ```python amr.xp.sin(src, out=dst) amr.xp.meshgrid(*axes, indexing="ij") ``` Without it every downstream script re-implements the same dispatch. We already resolve exactly this internally — `dlpack_helpers.xp_module_name()` returns `"numpy"`/`"cupy"`/`"dpnp"` and the `to_xp` methods dispatch on it — so this exposes the module that name refers to, keeping one source of truth. ### CuPy and dpnp remain optional dependencies `amr.xp` is resolved on **first attribute access** via a PEP 562 module `__getattr__`, never at import, so `import amrex` on a CUDA or SYCL build still works with neither installed. After the first access the module is cached in the module globals, bypassing `__getattr__` thereafter. There is a regression test for this, run in a subprocess so it holds independently of what the rest of the session imported: ```python assert "cupy" not in sys.modules assert "dpnp" not in sys.modules ``` If the library really is missing, the error names it rather than surfacing a bare `ModuleNotFoundError` for a name the user never typed: ``` ImportError: amrex.xp needs 'cupy', which is an optional dependency of pyAMReX and is not installed. Install it, or use the to_numpy()/to_cupy()/to_dpnp() methods directly. ``` Tests `importorskip` the optional library, matching `test_podvector.py`. `requirements.txt` and `setup.py` are untouched. The module `__getattr__` only intercepts `xp`; anything else raises the normal `AttributeError` (tested). ## 2. Factor the per-dimension module setup into a helper `space1d/2d/3d/__init__.py` were three near-identical copies differing only in the pybind module they wrap and in `d_decl()`. Adding `amr.xp` would have meant writing the same block a third time, so everything dimensionality-independent moves to `amrex._module_api.setup_module()` and each `__init__.py` keeps only its star-import and `d_decl()`. **About 130 lines become 25 per file.** Module-level names cannot be installed from the `register_*_extension()` functions the way class methods are, because `from .amrex_?d_pybind import *` has already run by then; the helper writes them into `globals()` instead. Injected callables get `__module__` set to the target module so that Sphinx `autofunction` and the CI stub generator keep attributing them to `amrex.space3d`. ### API impact The public API is unchanged: `Print`, `d_decl`, `read_particles` and `list_particle_species` keep their signatures, docstrings and `__module__`. Verified by diffing every callable of `amrex.space{1,2,3}d` before and after. The only differences are that seven `register_*_extension` names and `_read_particles` no longer leak into the `amrex.space?d` namespace. They are internal, documented nowhere, and WarpX imports its own `register_warpx_*` from `pywarpx.extensions` rather than these. Expect the CI-regenerated `.pyi` stubs to drop them. ## Testing `ctest --test-dir build --output-on-failure` green on a `AMReX_SPACEDIM="1;2;3" AMReX_OMP=ON` build, plus per-dimensionality checks of `amr.xp`, `d_decl` and the re-exported helpers in all three modules. CPU only locally — the CuPy and dpnp branches go through the shared `xp_module_name()` path but are not run on device here. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent e105179 commit ec5da58

6 files changed

Lines changed: 208 additions & 135 deletions

File tree

docs/source/usage/zerocopy.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,18 @@ See the optional arguments of this API.
5353

5454
Writing to the created NumPy/CuPy/dpnp array will also modify the underlying AMReX memory.
5555

56+
The matching array library itself is available as ``xp`` on the pyAMReX module, for code that has
57+
to call into it rather than just hold its arrays:
58+
59+
.. code-block:: python
60+
61+
import amrex.space3d as amr
62+
63+
arr = mfab.array(mfi).to_xp()
64+
amr.xp.sin(arr, out=arr) # numpy, cupy or dpnp, matching the build
65+
66+
It is resolved on first access, so importing pyAMReX does not import a GPU array library.
67+
5668

5769
GPU: numba
5870
----------

src/amrex/_module_api.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"""
2+
This file is part of pyAMReX
3+
4+
Copyright 2026 AMReX community
5+
Authors: Axel Huebl
6+
License: BSD-3-Clause-LBNL
7+
"""
8+
9+
from .extensions.Array4 import register_Array4_extension
10+
from .extensions.ArrayOfStructs import register_AoS_extension
11+
from .extensions.MultiFab import register_MultiFab_extension
12+
from .extensions.ParticleContainer import (
13+
list_particle_species,
14+
read_particles,
15+
register_ParticleContainer_extension,
16+
)
17+
from .extensions.PODVector import register_PODVector_extension
18+
from .extensions.SmallMatrix import register_SmallMatrix_extension
19+
from .extensions.StructOfArrays import register_SoA_extension
20+
21+
22+
def setup_module(ns, amr):
23+
"""Populate an ``amrex.space{1,2,3}d`` namespace.
24+
25+
Those three packages are identical apart from the compiled pybind module
26+
they wrap and their ``d_decl()``, so everything else is defined once here
27+
and installed into their namespace.
28+
29+
Class-level additions could equally be done from the ``register_*``
30+
functions, because a class object is shared. Module-level names cannot:
31+
``from .amrex_?d_pybind import *`` has already run by then, so a name added
32+
to the pybind module afterwards would not appear in the package namespace.
33+
They have to be written into ``ns`` instead, which is what this does.
34+
35+
Injected callables get their ``__module__`` set to the target module, so
36+
that Sphinx ``autofunction`` and the CI stub generator attribute them to
37+
``amrex.space3d`` rather than to this helper.
38+
39+
Parameters
40+
----------
41+
ns : dict
42+
The calling module's ``globals()``.
43+
amr : module
44+
That module's compiled bindings, e.g. ``amrex_3d_pybind``.
45+
"""
46+
name = ns["__name__"]
47+
48+
ns["__version__"] = amr.__version__
49+
ns["__doc__"] = amr.__doc__
50+
ns["__license__"] = amr.__license__
51+
ns["__author__"] = amr.__author__
52+
53+
# enhance the C++ classes with methods written in pure Python
54+
register_Array4_extension(amr)
55+
register_MultiFab_extension(amr)
56+
register_PODVector_extension(amr)
57+
register_SmallMatrix_extension(amr)
58+
register_SoA_extension(amr)
59+
register_AoS_extension(amr)
60+
register_ParticleContainer_extension(amr)
61+
62+
def Print(*args, **kwargs):
63+
"""Wrap amrex::Print() - only the IO processor writes"""
64+
if not amr.initialized():
65+
print("warning: Print all - AMReX not initialized")
66+
print(*args, **kwargs)
67+
elif amr.ParallelDescriptor.IOProcessor():
68+
print(*args, **kwargs)
69+
70+
def read_particles_(
71+
plotfile, particle_dir="particles", communicate=True, container=None
72+
):
73+
"""Read AMReX particle data from a plotfile/checkpoint into a container.
74+
75+
See :py:func:`amrex.extensions.ParticleContainer.read_particles` for details.
76+
"""
77+
return read_particles(amr, plotfile, particle_dir, communicate, container)
78+
79+
read_particles_.__name__ = "read_particles"
80+
read_particles_.__qualname__ = "read_particles"
81+
82+
def module_getattr(attr):
83+
"""Resolve ``xp`` lazily (PEP 562).
84+
85+
``amr.xp`` is the array namespace matching this build: NumPy on CPU,
86+
CuPy for CUDA/HIP, dpnp for SYCL. It is the module counterpart of the
87+
``to_xp`` methods, for code that needs to call into the array library
88+
itself, e.g. ``amr.xp.sin(...)``.
89+
90+
Like every other CuPy/dpnp use in pyAMReX, those are optional
91+
dependencies: they are imported here on first access, never at import
92+
time, so ``import amrex`` works on a GPU build without them. Only
93+
touching ``amr.xp`` (or a ``to_cupy``/``to_dpnp``/``to_xp`` call)
94+
requires one to be installed.
95+
96+
Raises
97+
------
98+
ImportError
99+
On a GPU build whose array library (CuPy or dpnp) is not installed.
100+
"""
101+
if attr == "xp":
102+
import importlib
103+
104+
from .extensions.dlpack_helpers import xp_module_name
105+
106+
module_name = xp_module_name(amr)
107+
try:
108+
xp = importlib.import_module(module_name)
109+
except ImportError as e:
110+
raise ImportError(
111+
f"amrex.xp needs {module_name!r}, which is an optional "
112+
f"dependency of pyAMReX and is not installed. Install it, "
113+
f"or use the to_numpy()/to_cupy()/to_dpnp() methods "
114+
f"directly."
115+
) from e
116+
ns["xp"] = xp # subsequent lookups skip __getattr__
117+
return xp
118+
raise AttributeError(f"module {name!r} has no attribute {attr!r}")
119+
120+
module_getattr.__name__ = "__getattr__"
121+
module_getattr.__qualname__ = "__getattr__"
122+
123+
ns["Print"] = Print
124+
ns["read_particles"] = read_particles_
125+
ns["list_particle_species"] = list_particle_species
126+
ns["__getattr__"] = module_getattr
127+
128+
for injected in (Print, read_particles_, module_getattr):
129+
injected.__module__ = name

src/amrex/space1d/__init__.py

Lines changed: 3 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,10 @@
77
add_windows_dll_directories(__file__)
88

99
# import core bindings to C++
10+
from .._module_api import setup_module as _setup_module
1011
from . import amrex_1d_pybind
1112
from .amrex_1d_pybind import * # noqa
1213

13-
__version__ = amrex_1d_pybind.__version__
14-
__doc__ = amrex_1d_pybind.__doc__
15-
__license__ = amrex_1d_pybind.__license__
16-
__author__ = amrex_1d_pybind.__author__
17-
1814

1915
# at this place we can enhance Python classes with additional methods written
2016
# in pure Python or add some other Python logic
@@ -24,43 +20,5 @@ def d_decl(x, y, z):
2420
return (x,)
2521

2622

27-
def Print(*args, **kwargs):
28-
"""Wrap amrex::Print() - only the IO processor writes"""
29-
if not initialized(): # noqa
30-
print("warning: Print all - AMReX not initialized")
31-
print(*args, **kwargs)
32-
elif ParallelDescriptor.IOProcessor(): # noqa
33-
print(*args, **kwargs)
34-
35-
36-
from ..extensions.Array4 import register_Array4_extension
37-
from ..extensions.ArrayOfStructs import register_AoS_extension
38-
from ..extensions.MultiFab import register_MultiFab_extension
39-
from ..extensions.ParticleContainer import register_ParticleContainer_extension
40-
from ..extensions.PODVector import register_PODVector_extension
41-
from ..extensions.SmallMatrix import register_SmallMatrix_extension
42-
from ..extensions.StructOfArrays import register_SoA_extension
43-
44-
register_Array4_extension(amrex_1d_pybind)
45-
register_MultiFab_extension(amrex_1d_pybind)
46-
register_PODVector_extension(amrex_1d_pybind)
47-
register_SmallMatrix_extension(amrex_1d_pybind)
48-
register_SoA_extension(amrex_1d_pybind)
49-
register_AoS_extension(amrex_1d_pybind)
50-
register_ParticleContainer_extension(amrex_1d_pybind)
51-
52-
53-
from ..extensions.ParticleContainer import list_particle_species # noqa
54-
from ..extensions.ParticleContainer import read_particles as _read_particles
55-
56-
57-
def read_particles(
58-
plotfile, particle_dir="particles", communicate=True, container=None
59-
):
60-
"""Read AMReX particle data from a plotfile/checkpoint into a container.
61-
62-
See :py:func:`amrex.extensions.ParticleContainer.read_particles` for details.
63-
"""
64-
return _read_particles(
65-
amrex_1d_pybind, plotfile, particle_dir, communicate, container
66-
)
23+
# everything else is the same for every dimensionality
24+
_setup_module(globals(), amrex_1d_pybind)

src/amrex/space2d/__init__.py

Lines changed: 3 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,10 @@
77
add_windows_dll_directories(__file__)
88

99
# import core bindings to C++
10+
from .._module_api import setup_module as _setup_module
1011
from . import amrex_2d_pybind
1112
from .amrex_2d_pybind import * # noqa
1213

13-
__version__ = amrex_2d_pybind.__version__
14-
__doc__ = amrex_2d_pybind.__doc__
15-
__license__ = amrex_2d_pybind.__license__
16-
__author__ = amrex_2d_pybind.__author__
17-
1814

1915
# at this place we can enhance Python classes with additional methods written
2016
# in pure Python or add some other Python logic
@@ -24,43 +20,5 @@ def d_decl(x, y, z):
2420
return (x, y)
2521

2622

27-
def Print(*args, **kwargs):
28-
"""Wrap amrex::Print() - only the IO processor writes"""
29-
if not initialized(): # noqa
30-
print("warning: Print all - AMReX not initialized")
31-
print(*args, **kwargs)
32-
elif ParallelDescriptor.IOProcessor(): # noqa
33-
print(*args, **kwargs)
34-
35-
36-
from ..extensions.Array4 import register_Array4_extension
37-
from ..extensions.ArrayOfStructs import register_AoS_extension
38-
from ..extensions.MultiFab import register_MultiFab_extension
39-
from ..extensions.ParticleContainer import register_ParticleContainer_extension
40-
from ..extensions.PODVector import register_PODVector_extension
41-
from ..extensions.SmallMatrix import register_SmallMatrix_extension
42-
from ..extensions.StructOfArrays import register_SoA_extension
43-
44-
register_Array4_extension(amrex_2d_pybind)
45-
register_MultiFab_extension(amrex_2d_pybind)
46-
register_PODVector_extension(amrex_2d_pybind)
47-
register_SmallMatrix_extension(amrex_2d_pybind)
48-
register_SoA_extension(amrex_2d_pybind)
49-
register_AoS_extension(amrex_2d_pybind)
50-
register_ParticleContainer_extension(amrex_2d_pybind)
51-
52-
53-
from ..extensions.ParticleContainer import list_particle_species # noqa
54-
from ..extensions.ParticleContainer import read_particles as _read_particles
55-
56-
57-
def read_particles(
58-
plotfile, particle_dir="particles", communicate=True, container=None
59-
):
60-
"""Read AMReX particle data from a plotfile/checkpoint into a container.
61-
62-
See :py:func:`amrex.extensions.ParticleContainer.read_particles` for details.
63-
"""
64-
return _read_particles(
65-
amrex_2d_pybind, plotfile, particle_dir, communicate, container
66-
)
23+
# everything else is the same for every dimensionality
24+
_setup_module(globals(), amrex_2d_pybind)

src/amrex/space3d/__init__.py

Lines changed: 3 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,10 @@
77
add_windows_dll_directories(__file__)
88

99
# import core bindings to C++
10+
from .._module_api import setup_module as _setup_module
1011
from . import amrex_3d_pybind
1112
from .amrex_3d_pybind import * # noqa
1213

13-
__version__ = amrex_3d_pybind.__version__
14-
__doc__ = amrex_3d_pybind.__doc__
15-
__license__ = amrex_3d_pybind.__license__
16-
__author__ = amrex_3d_pybind.__author__
17-
1814

1915
# at this place we can enhance Python classes with additional methods written
2016
# in pure Python or add some other Python logic
@@ -24,43 +20,5 @@ def d_decl(x, y, z):
2420
return (x, y, z)
2521

2622

27-
def Print(*args, **kwargs):
28-
"""Wrap amrex::Print() - only the IO processor writes"""
29-
if not initialized(): # noqa
30-
print("warning: Print all - AMReX not initialized")
31-
print(*args, **kwargs)
32-
elif ParallelDescriptor.IOProcessor(): # noqa
33-
print(*args, **kwargs)
34-
35-
36-
from ..extensions.Array4 import register_Array4_extension
37-
from ..extensions.ArrayOfStructs import register_AoS_extension
38-
from ..extensions.MultiFab import register_MultiFab_extension
39-
from ..extensions.ParticleContainer import register_ParticleContainer_extension
40-
from ..extensions.PODVector import register_PODVector_extension
41-
from ..extensions.SmallMatrix import register_SmallMatrix_extension
42-
from ..extensions.StructOfArrays import register_SoA_extension
43-
44-
register_Array4_extension(amrex_3d_pybind)
45-
register_MultiFab_extension(amrex_3d_pybind)
46-
register_PODVector_extension(amrex_3d_pybind)
47-
register_SmallMatrix_extension(amrex_3d_pybind)
48-
register_SoA_extension(amrex_3d_pybind)
49-
register_AoS_extension(amrex_3d_pybind)
50-
register_ParticleContainer_extension(amrex_3d_pybind)
51-
52-
53-
from ..extensions.ParticleContainer import list_particle_species # noqa
54-
from ..extensions.ParticleContainer import read_particles as _read_particles
55-
56-
57-
def read_particles(
58-
plotfile, particle_dir="particles", communicate=True, container=None
59-
):
60-
"""Read AMReX particle data from a plotfile/checkpoint into a container.
61-
62-
See :py:func:`amrex.extensions.ParticleContainer.read_particles` for details.
63-
"""
64-
return _read_particles(
65-
amrex_3d_pybind, plotfile, particle_dir, communicate, container
66-
)
23+
# everything else is the same for every dimensionality
24+
_setup_module(globals(), amrex_3d_pybind)

tests/test_xp.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# -*- coding: utf-8 -*-
2+
3+
import subprocess
4+
import sys
5+
6+
import pytest
7+
8+
import amrex.space3d as amr
9+
from amrex.extensions.dlpack_helpers import xp_module_name
10+
11+
# CuPy/dpnp are optional dependencies, so a GPU build without them installed
12+
# must still import -- only touching amr.xp may then fail.
13+
XP_NAME = xp_module_name(amr)
14+
15+
16+
def test_xp_matches_build():
17+
"""amr.xp is the array namespace this build was compiled for."""
18+
expected = {
19+
None: "numpy",
20+
"CUDA": "cupy",
21+
"HIP": "cupy",
22+
"SYCL": "dpnp",
23+
}[amr.Config.gpu_backend]
24+
assert XP_NAME == expected
25+
26+
pytest.importorskip(XP_NAME, reason=f"optional dependency {XP_NAME} not installed")
27+
assert amr.xp.__name__ == expected
28+
29+
30+
def test_xp_is_cached():
31+
"""PEP 562 __getattr__ resolves once, then the global shadows it."""
32+
pytest.importorskip(XP_NAME, reason=f"optional dependency {XP_NAME} not installed")
33+
assert amr.xp is amr.xp
34+
35+
36+
def test_xp_getattr_still_raises():
37+
"""The module __getattr__ must not swallow genuine attribute errors."""
38+
with pytest.raises(AttributeError, match="no attribute"):
39+
amr.this_does_not_exist
40+
41+
42+
def test_import_does_not_pull_in_gpu_array_library():
43+
"""Importing pyAMReX must not import CuPy or dpnp.
44+
45+
They are optional dependencies. Run in a subprocess so this holds
46+
regardless of what the rest of the test session has already imported.
47+
"""
48+
code = (
49+
"import sys; import amrex.space3d as amr; "
50+
"assert 'cupy' not in sys.modules, 'cupy imported by import amrex'; "
51+
"assert 'dpnp' not in sys.modules, 'dpnp imported by import amrex'; "
52+
"print('clean')"
53+
)
54+
out = subprocess.run(
55+
[sys.executable, "-c", code], capture_output=True, text=True, check=False
56+
)
57+
assert out.returncode == 0, out.stderr
58+
assert "clean" in out.stdout

0 commit comments

Comments
 (0)