Skip to content

Commit a996df3

Browse files
authored
refactor: ♻️ adopt the array API standard for backend dispatch and array typing (#69)
* refactor: ♻️ adopt the array API standard for backend dispatch and array typing The algorithms take the array namespace from their arrays through array-api-compat instead of receiving an `xp` module. The namespace is picked once from `device=` by `DeviceGroup`, which now also resolves the device ids; `EnvironmentStore` follows the arrays it is given, and `truncated_qr` and the splitting kernels follow their inputs. Arrays of either backend are annotated with one alias, `src_method.utils.Array`; host-only arrays (the results, the exact path, the sketches) are `np.ndarray`. Array creation, `linalg.qr`, `conj`, `reshape`, `moveaxis`, `concat` and `asarray` use the standard names. Streams, pinned memory and CuPy's pool stay behind `utils._backend`, and the Gaussian sketches are always drawn on the host. Closes #45 Assisted-by: GitHub Copilot:Claude Opus 5.5 * chore: clean-up readme.
1 parent 3099627 commit a996df3

27 files changed

Lines changed: 293 additions & 353 deletions

‎AGENTS.md‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,12 @@ tensor, so there is no wrapper type.
2020
`A`); that licence does not extend to anything else.
2121
- Comment invariants, contracts and non-obvious numerical choices only. Never
2222
narrate the code.
23-
- Backend-agnostic code: go through `src_method.utils._backend` instead of
24-
importing `numpy` or `cupy` directly in the algorithms, so CPU and GPU paths
25-
stay in sync.
23+
- Backend-agnostic code: use the array API standard, with the namespace taken
24+
from the arrays (`array_api_compat.array_namespace`) or from the
25+
`DeviceGroup`, never an `xp` parameter; annotate arrays as
26+
`src_method.utils.Array`. What the standard lacks (streams, pinned memory,
27+
CuPy's pool) goes through `src_method.utils._backend`. Never import `cupy`
28+
in the algorithms, so CPU and GPU paths stay in sync.
2629
- Log with the standard library: `logger = logging.getLogger(__name__)` per
2730
module, lazy `%`-style arguments, never `print`. Never add handlers, levels
2831
or `basicConfig` in the package; progress messages go at `DEBUG`.

‎README.md‎

Lines changed: 4 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
[![Documentation](https://github.com/Algorithmiq/src-method/actions/workflows/docpages.yml/badge.svg)](https://algorithmiq.github.io/src-method/)
44
[![Test src_method](https://github.com/Algorithmiq/src-method/actions/workflows/test.yml/badge.svg)](https://github.com/Algorithmiq/src-method/actions/workflows/test.yml)
5+
[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=Algorithmiq_src-method&metric=coverage&token=ab544eca4948ef2f80dffa566c31a9c00026a825)](https://sonarcloud.io/summary/new_code?id=Algorithmiq_src-method)
6+
[![Quality gate status](https://sonarcloud.io/api/project_badges/measure?project=Algorithmiq_src-method&metric=alert_status&token=ab544eca4948ef2f80dffa566c31a9c00026a825)](https://sonarcloud.io/summary/new_code?id=Algorithmiq_src-method)
57
[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE)
68

79
An implementation of the SRC algorithm introduced in [Camaño, Epperly and Tropp, *Quantum* **10**, 2022 (2026)](https://doi.org/10.22331/q-2026-03-10-2022), extending the idea to other kinds of tensor networks.
@@ -109,21 +111,6 @@ On a node with several GPUs, `Resources(devices=4)` splits the sweep among four
109111

110112
See [Large problems](https://algorithmiq.github.io/src-method/features/large-problems) for the budgets, the scratch directory, several GPUs and how to read the logged plan.
111113

112-
### Logging
113-
114-
`src_method` logs through the standard library `logging` module, under the
115-
`src_method` logger, and stays silent by default: it adds only a `NullHandler`
116-
and never touches the root logger or its handlers. Progress and timing messages
117-
are emitted at `DEBUG`, the small-network fallback at `WARNING`. To see them,
118-
configure logging in your application:
119-
120-
```python
121-
import logging
122-
123-
logging.basicConfig(level=logging.INFO)
124-
logging.getLogger("src_method").setLevel(logging.DEBUG)
125-
```
126-
127114
## Installation
128115

129116
```bash
@@ -150,45 +137,9 @@ dependencies = ["src_method[gpu-nvidia]"]
150137
dependencies = ["src_method[gpu-rocm]"]
151138
```
152139

153-
## Setting up the development environment
154-
155-
The code has a [DevContainer] configuration that will get you up and running
156-
with all dependencies installed and configured, including sane defaults for the
157-
editor.
158-
159-
You will need:
160-
161-
1. A working [Docker] installation:
162-
- For macOS and Windows, install [Docker Desktop](https://docs.docker.com/get-docker/)
163-
- For Linux, install [Docker Engine](https://docs.docker.com/engine/install/#server) following the instructions for your specific distro.
164-
2. The [Visual Studio Code] editor. A recent version is recommended, _e.g._ >=1.78
165-
3. The VSCode [DevContainers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers).
166-
4. The [GitHub CLI] tool.
167-
168-
You can clone the repository with:
169-
170-
```
171-
gh repo clone Algorithmiq/src-method
172-
```
173-
174-
We recommend using a Git credential manager, such as [GitHub CLI], configured to
175-
use HTTPS as protocol for Git operations.
176-
177-
Once the code is locally available, you can open its containing folder in
178-
[Visual Studio Code]. The editor will then set up the [DevContainer] for you.
179-
The first time you open the folder the startup will take a few minutes. Once the
180-
process is done, you will have _all_ project dependencies installed, including
181-
the git hooks.
182-
[Visual Studio Code] will be already configured with all the extensions helpful for Python development.
183-
184-
> [!TIP]
185-
> The order in which Visual Studio Code loads the extensions in the
186-
> DevContainer is non-deterministic. You might have to execute the *Reload Window*
187-
> command to get everything to work as expected after a fresh build of the container.
188-
189-
### Alternative: Nix flake
140+
## Nix flake
190141

191-
If you prefer [Nix] over Docker, the repository ships a `flake.nix` that provides
142+
The repository ships a `flake.nix` that provides
192143
a development shell with [uv], [Git] and the [GitHub CLI], plus the native
193144
libraries the binary wheels need at runtime. Python itself and all project
194145
dependencies remain managed by `uv`.
@@ -219,28 +170,9 @@ The documentation is a [Fumadocs] site under `docs/`, published [at this link].
219170
It covers concepts, features, tutorials, the API reference generated from the
220171
docstrings, and the developer guide.
221172

222-
To preview it locally you need [Node.js] 22 or later:
223-
224-
```bash
225-
uv sync --group docs
226-
cd docs
227-
npm ci
228-
uv run python scripts/gen_api_dump.py src_method -d .
229-
node scripts/generate-api.mjs
230-
uv run python scripts/notebooks_to_mdx.py
231-
npm run dev
232-
```
233-
234-
and open http://localhost:3000. See [`docs/README.md`](docs/README.md) for details.
235-
236-
[DevContainer]: https://containers.dev/
237-
[Docker]: https://docs.docker.com/get-docker/
238-
[Visual Studio Code]: https://code.visualstudio.com/
239173
[GitHub CLI]: https://cli.github.com/
240174
[Git]: https://git-scm.com/
241-
[Nix]: https://nixos.org/download/
242175
[direnv]: https://direnv.net/
243176
[uv]: https://docs.astral.sh/uv/
244177
[Fumadocs]: https://fumadocs.dev
245-
[Node.js]: https://nodejs.org
246178
[at this link]: https://algorithmiq.github.io/src-method/

‎docs/content/docs/contributing/dependencies.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ Dependencies are managed with [uv](https://docs.astral.sh/uv/) and declared in
77
`pyproject.toml`:
88

99
- **Runtime dependencies** (`[project] dependencies`) are what users install:
10-
NumPy and `opt_einsum`. Keep this list short; every entry is imposed on every
11-
downstream project.
10+
NumPy, `opt_einsum` and `array-api-compat`. Keep this list short; every entry is
11+
imposed on every downstream project.
1212
- **Extras** (`[project.optional-dependencies]`) enable optional features in
1313
production: `gpu-nvidia` and `gpu-rocm` add CuPy.
1414
- **Dependency groups** (`[dependency-groups]`) are for development only and are

‎docs/content/docs/contributing/index.mdx‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,5 +74,9 @@ not just the shapes.
7474
The project targets Python 3.11+, is checked with `ruff` under a strict rule set
7575
and type-checked with [`ty`](https://github.com/astral-sh/ty). Public functions
7676
carry type hints and Google-style docstrings without types in the argument list.
77-
The algorithms go through `src_method.utils._backend` rather than importing NumPy
78-
or CuPy directly, so the CPU and GPU paths stay in sync.
77+
The algorithms speak the [array API standard](https://data-apis.org/array-api/)
78+
through `array-api-compat`: they take the namespace from their arrays
79+
(`array_namespace(x)`) instead of importing NumPy or CuPy, and annotate them as
80+
`src_method.utils.Array`. What the standard leaves out (streams, page-locked
81+
memory, CuPy's memory pool, host-side random draws) lives in
82+
`src_method.utils._backend`, so the CPU and GPU paths stay in sync.

‎docs/content/docs/getting-started.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ uv pip install "src_method[gpu-nvidia]"
1616
uv pip install "src_method[gpu-rocm]"
1717
```
1818

19-
The runtime dependencies are NumPy and `opt_einsum`. The GPU extras add CuPy;
20-
see [GPU execution](/features/gpu).
19+
The runtime dependencies are NumPy, `opt_einsum` and `array-api-compat`. The GPU
20+
extras add CuPy; see [GPU execution](/features/gpu).
2121

2222
## The three entry points
2323

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ classifiers = [
3232
"Typing :: Typed",
3333
]
3434
dynamic = ["version"]
35-
dependencies = ["numpy>=2.0,<3", "opt_einsum>=3.4.0"]
35+
dependencies = ["array-api-compat>=1.12", "numpy>=2.0,<3", "opt_einsum>=3.4.0"]
3636

3737

3838
[project.optional-dependencies]

‎src/src_method/_group.py‎

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,21 @@
1919
from concurrent.futures import ThreadPoolExecutor
2020
from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar
2121

22-
from .utils import copy_into, current_stream, enable_peer_access, use_device
22+
from ._plan import device_ids
23+
from .utils import (
24+
copy_into,
25+
current_device,
26+
current_stream,
27+
device_count,
28+
enable_peer_access,
29+
get_xp,
30+
use_device,
31+
)
2332

2433
if TYPE_CHECKING:
2534
from collections.abc import Callable, Sequence
26-
from types import ModuleType
2735

28-
from numpy.typing import NDArray
36+
from .utils import Array
2937

3038
T = TypeVar("T")
3139

@@ -60,17 +68,26 @@ class DeviceGroup:
6068
"""The devices of one sweep and the collectives between them.
6169
6270
Args:
63-
xp: Array module (``numpy`` or ``cupy``).
64-
devices: The device ids, in rank order; rank 0 is the root.
71+
device: ``"cpu"`` or ``"gpu"``, as in `src_method.stack.src`.
72+
devices: A count ``n`` for the first ``n`` visible devices, or the device
73+
ids in rank order; rank 0 is the root. Defaults to the current device.
74+
75+
Raises:
76+
ValueError: If a device does not exist.
6577
"""
6678

67-
def __init__(self, xp: ModuleType, devices: Sequence[int]) -> None:
68-
self.xp = xp
69-
self.devices = tuple(devices)
79+
def __init__(self, device: str, devices: int | Sequence[int] | None = None) -> None:
80+
self.xp = get_xp(device)
81+
ids = device_ids(devices) or (current_device(self.xp),)
82+
count = device_count(self.xp)
83+
if count is not None and max(ids) >= count:
84+
msg = f"Requested devices {list(ids)}, but only {count} are visible."
85+
raise ValueError(msg)
86+
self.devices = ids
7087
self.size = len(self.devices)
7188
# Uploading 1/size of each core and gathering the rest pays off only when
7289
# the devices copy directly between them.
73-
self.split_uploads = self.size > 1 and enable_peer_access(xp, self.devices)
90+
self.split_uploads = self.size > 1 and enable_peer_access(self.xp, ids)
7491
self._barrier = threading.Barrier(self.size)
7592
# Two sets of slots, alternating between consecutive collectives: a rank may
7693
# publish into the next collective while slower ranks still read this one.
@@ -171,12 +188,12 @@ def _settle(self, rank: int, parity: int) -> None:
171188
stream.wait_event(event)
172189
self._parts[parity][rank] = None
173190

174-
def _pull(self, part: _Part, dst: NDArray) -> None:
191+
def _pull(self, part: _Part, dst: Array) -> None:
175192
stream = current_stream(self.xp)
176193
stream.wait_event(part.ready)
177-
copy_into(dst, part.array, self.xp, stream)
194+
copy_into(dst, part.array, stream)
178195

179-
def allgather(self, rank: int, local: NDArray) -> NDArray:
196+
def allgather(self, rank: int, local: Array) -> Array:
180197
"""Concatenate the blocks of every rank along the first axis, on every rank.
181198
182199
Args:
@@ -199,7 +216,7 @@ def allgather(self, rank: int, local: NDArray) -> NDArray:
199216
self._settle(rank, parity)
200217
return full
201218

202-
def gather(self, rank: int, local: NDArray, root: int = 0) -> NDArray | None:
219+
def gather(self, rank: int, local: Array, root: int = 0) -> Array | None:
203220
"""Concatenate the blocks of every rank along the first axis, on ``root``.
204221
205222
Args:
@@ -224,7 +241,7 @@ def gather(self, rank: int, local: NDArray, root: int = 0) -> NDArray | None:
224241
self._settle(rank, parity)
225242
return full
226243

227-
def scatter(self, rank: int, full: NDArray | None, root: int = 0) -> NDArray:
244+
def scatter(self, rank: int, full: Array | None, root: int = 0) -> Array:
228245
"""Give every rank its block (see `blocks`) of the first axis of ``full``.
229246
230247
Args:
@@ -246,7 +263,7 @@ def scatter(self, rank: int, full: NDArray | None, root: int = 0) -> NDArray:
246263
self._settle(rank, parity)
247264
return local
248265

249-
def _empty_like_parts(self, parts: Sequence[_Part]) -> NDArray:
266+
def _empty_like_parts(self, parts: Sequence[_Part]) -> Array:
250267
first = parts[0].array
251268
rows = sum(part.array.shape[0] for part in parts)
252269
return self.xp.empty((rows, *first.shape[1:]), dtype=first.dtype)

‎src/src_method/_kernels.py‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,10 @@
2020
if TYPE_CHECKING:
2121
from collections.abc import Sequence
2222

23-
from numpy.typing import NDArray
2423
from opt_einsum.contract import ContractExpression
2524

25+
from .utils import Array
26+
2627
Shape = tuple[int, ...]
2728

2829

@@ -71,7 +72,7 @@ class _Contractions:
7172
def __init__(self) -> None:
7273
self._compiled: dict[tuple[str, tuple[Shape, ...]], ContractExpression] = {}
7374

74-
def __call__(self, eq: str, *operands: NDArray) -> NDArray:
75+
def __call__(self, eq: str, *operands: Array) -> Array:
7576
shapes = tuple(op.shape for op in operands)
7677
expr = self._compiled.get((eq, shapes))
7778
if expr is None:
@@ -90,7 +91,7 @@ def __init__(self, depth: int) -> None:
9091
self.eqs = equations(depth)
9192
self._contract = _Contractions()
9293

93-
def env(self, env: NDArray, omega: NDArray, cores: Sequence[NDArray]) -> NDArray:
94+
def env(self, env: Array, omega: Array, cores: Sequence[Array]) -> Array:
9495
"""Advance a batch of sketch columns of the environment by one site.
9596
9697
Args:
@@ -103,7 +104,7 @@ def env(self, env: NDArray, omega: NDArray, cores: Sequence[NDArray]) -> NDArray
103104
"""
104105
return self._contract(self.eqs.ltr, env, omega, *cores)
105106

106-
def sketch(self, env: NDArray, cores: Sequence[NDArray], proj: NDArray) -> NDArray:
107+
def sketch(self, env: Array, cores: Sequence[Array], proj: Array) -> Array:
107108
"""Sketch a batch of columns of the site's running core.
108109
109110
Args:
@@ -116,7 +117,7 @@ def sketch(self, env: NDArray, cores: Sequence[NDArray], proj: NDArray) -> NDArr
116117
"""
117118
return self._contract(self.eqs.rtl_m, env, *cores, proj)
118119

119-
def project(self, eta: NDArray, cores: Sequence[NDArray], proj: NDArray) -> NDArray:
120+
def project(self, eta: Array, cores: Sequence[Array], proj: Array) -> Array:
120121
"""Project a batch of rows of the new projected environment.
121122
122123
Args:
@@ -129,7 +130,7 @@ def project(self, eta: NDArray, cores: Sequence[NDArray], proj: NDArray) -> NDAr
129130
"""
130131
return self._contract(self.eqs.rtl_s, eta, *cores, proj)
131132

132-
def first(self, cores: Sequence[NDArray], proj: NDArray) -> NDArray:
133+
def first(self, cores: Sequence[Array], proj: Array) -> Array:
133134
"""Contract the first site with a batch of rows of ``S``.
134135
135136
Args:

0 commit comments

Comments
 (0)