Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,8 @@ jobs:
run: |
black --check src/

# stubs/ declares what arraybridge calls of each library it imports, so
# nothing but mypy needs installing; naming it checks the declarations too.
- name: Run mypy (type checking)
run: |
mypy src/
mypy src/ stubs/
103 changes: 92 additions & 11 deletions ADDING_NEW_FRAMEWORKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,45 @@ class FrozenLibrary(HostDevices[FrozenArray]):
def _move_to_host(self, data):
return data

# The inherited default calls ``self.module.stack``; this module has none.
# Array operations: nothing is inherited for them, because the
# base class assumes no array API. This array wraps NumPy values.
def accepts(self, value):
return isinstance(value, FrozenArray)

def max(self, data, axis):
return FrozenArray(data.values.max(axis=axis))

def min(self, data, axis):
return FrozenArray(data.values.min(axis=axis))

def mean(self, data, axis):
return FrozenArray(data.values.mean(axis=axis))

def sum(self, data, axis):
return FrozenArray(data.values.sum(axis=axis))

def assign(self, data, index, values):
updated = data.values.copy()
updated[index] = values.values
return FrozenArray(updated)

def _stack(self, values):
return FrozenArray(np.stack([value.values for value in values]))

def _cast(self, data, dtype):
return FrozenArray(data.values.astype(dtype))

def _logical_and(self, left, right):
return FrozenArray(np.logical_and(left.values, right.values))

def _reshape(self, data, shape):
return FrozenArray(data.values.reshape(shape))

def _broadcast_to(self, data, shape):
return FrozenArray(np.broadcast_to(data.values, shape))

def _ones_like(self, reference, shape, dtype):
return FrozenArray(np.ones(shape, dtype=dtype))


# Lookup, by name and by array.
Expand Down Expand Up @@ -124,7 +160,8 @@ assert first_row(frozen).shape == (3,)
| `oom_messages` | Lower-case text that marks an out-of-memory error message. | `()` |
| `oom_exception_paths` | Attribute paths from the module to its out-of-memory exception classes. | `()` |

Every library defines three methods asked for by `ArrayLibrary` itself:
Every library defines three methods asked for by `ArrayLibrary` itself, and
the array operations listed under "Array operations" below:

- `device_of(data)`: the device holding the array. It never returns `None`.
- `_to_host(data)`: the array as a NumPy array.
Expand All @@ -139,12 +176,11 @@ class must define.
| Parent | The library... | Abstract methods |
|---|---|---|
| `HostDevices` | keeps arrays in main memory. | `_wrap_host_array(data)`, `_move_to_host(data)` |
| `AcceleratorDevices` | can keep arrays on numbered accelerators. | `_accelerator_count()`, `_accelerator_scope(device)`, `_move_to_accelerator(data, device)`, `_upload(data, device)` |
| `CudaLibrary` | is an `AcceleratorDevices` library with its own CUDA streams and a memory cache to release. | those of `AcceleratorDevices`, plus `stream_scope(stream)`, `current_device()`, `_free_cached_memory()` |
| `AcceleratorDevices` | can keep arrays on numbered accelerators. | `_accelerator_count()`, `_accelerator_scope(device)`, `_move_to_accelerator(data, device)`, `_upload(data, device)`, `_device_if_accepted(value)` |
| `CudaLibrary` | is an `AcceleratorDevices` library with its own CUDA streams and a memory cache to release. | those of `AcceleratorDevices`, plus `create_stream()`, `stream_scope(stream)`, `current_device()`, `_free_cached_memory()` |
| `DLPackImport` | can wrap another library's buffer through DLPack. | `_import_dlpack(payload)` |
| `DLPackProtocolExport` | has arrays with the standard DLPack export methods on every device. | none |
| `NvidiaWheelLibraries` | loads CUDA from the `nvidia-*` wheels; child processes get their library directories on the search path. | none |
| `MutableArrays` | has the NumPy array API and arrays that can be changed in place; supplies `_scale_dtype` and in-place plane normalisation. | `_rescales_into(target_dtype)` |

Methods with a default worth knowing:

Expand All @@ -154,18 +190,63 @@ Methods with a default worth knowing:
whole process. The default does nothing.
- `cleanup_loaded(device=None)`: release cached device memory. `CudaLibrary`
implements it; elsewhere the default does nothing.
- `DLPackImport._reads_dlpack(payload)`: whether the importer can read this
payload. The default says yes; where it says no, `from_dlpack` returns
`NotImplemented` and `_import_dlpack` is not called.
- `exports_dlpack_from`, `exports_dlpack_data`, `export_dlpack`: override
these directly when export depends on the device or the array, as
TensorFlow does.
- `_stack`, `_cast`, `_reshape`, `_broadcast_to`, `_ones_like`,
`_logical_and`, `max`, `min`, `mean`, `sum`, `linspace`, `ones`, `assign`,
`outer`, `floor`, `accepts`, `nbytes_of`, `dtype_name`: the defaults call
NumPy-named functions on the library's module or methods on the array.
Override each one whose call or numeric rule differs.
- `nbytes_of`, `dtype_name`: the defaults read `shape` and `dtype` from the
array and name the dtype as NumPy does. Override where the library differs.

## Array operations

`ArrayLibrary` asks every library for `accepts`, `max`, `min`, `mean`, `sum`,
`assign`, `_stack`, `_cast`, `_reshape`, `_broadcast_to`, `_ones_like` and
`_logical_and`, and gives no default for any of them: the base class assumes
no array API. A library defines them or inherits them from a parent it
declares.

| Parent | The library... | Supplies | Abstract methods |
|---|---|---|---|
| `InPlaceAssignment` | has arrays that take `array[index] = values`. | `assign` | none |
| `ArrayMath` | can make arrays without a reference array, on its default device. A library that cannot (pyclesperanto) does not inherit it and has none of these four methods. | nothing | `linspace`, `ones`, `outer`, `floor` |
| `NumpyArrayApi` | has an array module and arrays that follow the NumPy API (`array_module.max(data, axis=...)`, `data.astype(...)`, ...). It is an `ArrayMath` library. | every operation above except `assign`, and the four of `ArrayMath` | `array_module` |
| `MutableArrays` | is a `NumpyArrayApi` and `InPlaceAssignment` library; supplies `_scale_dtype` and in-place plane normalisation. | all of the above | `array_module`, `_rescales_into(target_dtype)` |

Override an inherited operation only where the library's numeric rule
differs (JAX's `mean` casts to float32 first).

The six built-in classes at the end of `src/arraybridge/libraries.py` are the
reference for each combination of roles.

## Typing

The checker reads a library from `stubs/<library>/`, never from an installed
copy, so `mypy` gives the same result on every machine. For a new library:

- declare in `stubs/<library>/` exactly the functions, classes and attributes
the class calls, with the library's own names and types;
- state the module in the class, so its calls are checked against that
declaration:

```python
if TYPE_CHECKING:
import mylib

class MylibLibrary(HostDevices["mylib.Array"]):
name = "mylib"

if TYPE_CHECKING:
module = mylib
```

- call `self.module.<function>(...)` directly: a local `module = self.module`
is a plain `ModuleType` to the checker and loses the declaration;
- a `NumpyArrayApi` library returns its module of NumPy-named functions from
`array_module`; the checker compares that module with the `NumpyFunctions`
Protocol (`MutableNumpyFunctions` for `MutableArrays`).

## Tests to add

- detection by array and by module name, and behaviour when the package is
Expand All @@ -181,7 +262,7 @@ Run:
```bash
ruff check src tests
black --check src tests
mypy src --ignore-missing-imports
mypy src stubs
pytest
python -m sphinx -E -W --keep-going -b html docs/source /tmp/arraybridge-docs
```
Expand Down
85 changes: 85 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,89 @@
# Changelog

## 0.5.1

### No library inherits an operation it cannot perform

`ArrayLibrary` no longer supplies NumPy-style implementations. In 0.5.0 every
library inherited `self.array_module.linspace(...)`, `.ones`, `.outer`,
`.floor`, `.max(axis=...)`, `data.astype(...)` and so on, whether or not its
module had those functions. They now live in parents a library declares:

| Parent | Supplies | Inherited by |
|---|---|---|
| `NumpyArrayApi` | every array operation, by NumPy's names on `array_module` | NumPy, CuPy, JAX |
| `ArrayMath` | declares `linspace`, `ones`, `outer`, `floor` (abstract) | `NumpyArrayApi`, torch, TensorFlow |
| `InPlaceAssignment` | `assign` by `array[index] = values` | `MutableArrays` (NumPy, CuPy), torch, pyclesperanto |

`MutableArrays` is now a `NumpyArrayApi` and `InPlaceAssignment` library.
`accepts`, `max`, `min`, `mean`, `sum`, `assign`, `_stack`, `_cast`,
`_reshape`, `_broadcast_to`, `_ones_like` and `_logical_and` are abstract in
`ArrayLibrary`. Torch and TensorFlow state the few they used to inherit; JAX
drops four overrides that only repeated the NumPy spelling.

What changes for callers: `PyclesperantoLibrary` has no `linspace`, `ones`,
`outer` or `floor`. In 0.5.0 `ArrayLibrary.named("pyclesperanto").linspace(...)`
existed and failed inside the call; it is now an `AttributeError` at the
attribute. Ask `isinstance(library, ArrayMath)`. Nothing else is renamed or
removed, and every other built-in library answers every operation as before.

What changes for a library class written outside the package: it defines the
operations above or inherits `NumpyArrayApi` (`ADDING_NEW_FRAMEWORKS.md` shows
both).

### A decorated call in main memory does not touch the accelerator

The wrapper a library decorator puts around a function (`execution_wrapper`)
reads the device from the call's arguments. In 0.5.0 it asked the library for
its selected device whenever the machine had an accelerator, which for torch
calls `torch.cuda.current_device()`: CUDA was initialised and a stream opened
for a function working on host tensors.

- Arguments held on accelerator N: the call runs on this thread's stream for
device N, with out-of-memory recovery for that device. (0.5.0 used the
stream of the selected device even when the data was on another one.)
- Arguments in main memory, or no array of a library that has main memory
(torch, TensorFlow, JAX): the function is called as written. Nothing of the
accelerator is asked, and there is no out-of-memory retry.
- A library without main memory (CuPy, pyclesperanto) called without one of
its arrays runs on its selected device, as before.

### Typing

`mypy` runs with `warn_return_any` and without `ignore_missing_imports`. Every
library the package imports is declared in the repository's `stubs/`
directory, which lists what arraybridge calls and comes before any installed
copy, so the result is the same on every machine. A library class states that
its `module` is that library; `NumpyArrayApi` names its module by the
`NumpyFunctions` Protocol. For a class written outside the package this means:
`NumpyArrayApi` and `MutableArrays` leave `array_module` abstract,
`AcceleratorDevices` asks for `_device_if_accepted(value)`, `CudaLibrary` for
`create_stream()`, and a `DLPackImport` library that cannot read a payload says
so in `_reads_dlpack(payload)` instead of returning `NotImplemented` from
`_import_dlpack`.

Annotations that now say what was already returned: a device or stream scope
is an `AbstractContextManager[object]` (CuPy's device and stream are their own
context managers), `from_dlpack` returns the array or `NotImplementedType`,
and `ArrayLibrary.array_module` is an `object` in the base class.

A value of the wrong kind is now refused with `TypeError` in four places:
`MemoryContractAttribute.read` when the stored value is neither a string nor
`None`, `TorchLibrary.stream_scope` for an object that is neither a
`torch.cuda.Stream` nor `None`, a dtype lookup in torch or TensorFlow whose
attribute is not a dtype of that library, and `DeviceMove.convert` called with
two different libraries.

`MemoryType`'s metaclass overrides are annotated with `EnumMeta`'s signatures;
its `__call__` override is gone (`Enum` already asks `_missing_`).

### Not verified on a GPU

0.5.1 was tested with NumPy on Python 3.10, 3.12 and 3.13 and with the torch
CPU wheel on 3.12. CuPy, TensorFlow, JAX and pyclesperanto, and torch on CUDA,
were exercised only through the fakes in `tests/fakes.py`. `TESTING.md` has
the commands for a GPU machine.

## 0.5.0

Each supported array library is now one class of one family,
Expand Down Expand Up @@ -120,3 +204,4 @@ cannot wrap one; the result (a host copy) is unchanged and the warning is gone.
nominal type of an array of any supported library; `library.array_class` is an
array of that library. Native array classes are registered with them when a
library first meets one.

7 changes: 3 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ ruff check src/ tests/ --fix

```bash
# Run type checker
mypy src/ --ignore-missing-imports
mypy src stubs

# Check specific file
mypy src/arraybridge/converters.py
Expand All @@ -274,7 +274,7 @@ pytest
pytest --cov=arraybridge --cov-report=term

# 5. Type check (optional but recommended)
mypy src/ --ignore-missing-imports
mypy src stubs
```

## Submitting Changes
Expand Down Expand Up @@ -355,7 +355,7 @@ Add release notes to `CHANGELOG.md` (if exists) or create GitHub release notes.
```bash
ruff check src tests scripts
black --check src scripts
mypy src --ignore-missing-imports
mypy src stubs
pytest
python -m sphinx -E -W --keep-going -b html docs/source /tmp/arraybridge-docs
git commit
Expand Down Expand Up @@ -403,7 +403,6 @@ GitHub Actions will automatically:
2. **Start small**: Begin with MVP, iterate based on feedback
3. **Document**: Add docstrings, update README/docs
4. **Test thoroughly**: Include unit, integration, and edge case tests
5. **Maintain compatibility**: Avoid breaking changes when possible

### Adding Library Support

Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,9 @@ that library, on the device of their input: `max`, `min`, `mean`, `sum`,
`astype`, `reshape`, `broadcast_to`, `ones_like`, `ones`, `prepend_axis`,
`linspace`, `assign`, `outer`, `floor`, `logical_and`, `stack_arrays`,
`normalize_planes`, `scale_dtype`, `to_numpy`, `from_numpy`.
`linspace`, `ones`, `outer` and `floor` exist only on libraries that can make
an array without a reference array (`ArrayMath`: every built-in library but
pyclesperanto).

```python
import numpy as np
Expand Down
37 changes: 36 additions & 1 deletion TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ are the authority for the current CI matrix.
python -m pip install -e ".[dev,docs]"
ruff check src tests
black --check src tests
mypy src --ignore-missing-imports
mypy src stubs
pytest
python -m sphinx -E -W --keep-going -b html docs/source /tmp/arraybridge-docs
```
Expand Down Expand Up @@ -37,6 +37,41 @@ that declaration-owned import defaults work. Framework warnings about shared
CUDA plugin registration should be recorded separately from value, device, or
shape failures.

## Commands for a GPU machine

0.5.0's GPU run predates three later fixes, and 0.5.1 was not run on a GPU.
On a machine with CUDA 12 and about 15 GB of free disk:

```bash
git clone https://github.com/OpenHCSDev/ArrayBridge.git && cd ArrayBridge
git checkout v0.5.1
uv venv -p 3.12 .venv-gpu
uv pip install -p .venv-gpu/bin/python -e ".[dev,gpu]"
.venv-gpu/bin/python -m pytest -q --no-cov -ra
.venv-gpu/bin/python -m pytest -q --no-cov -ra -m "gpu or cupy or torch or tensorflow or jax or pyclesperanto"
.venv-gpu/bin/python -m pytest -q --no-cov -ra tests/test_operation_soundness.py
.venv-gpu/bin/python host_call_check.py
```

`tests/test_operation_soundness.py` calls every array operation of every
installed library on its first device; no skip should name an installed
library. `host_call_check.py` is:

```python
import torch
from arraybridge import torch as on_torch

@on_torch
def add_one(image):
return image + 1

add_one(torch.ones(2, 3, 3))
assert not torch.cuda.is_initialized(), "a host call initialised CUDA"
if torch.cuda.is_available():
assert add_one(torch.ones(2, 3, 3, device="cuda:0")).is_cuda
print("ok")
```

## Interpreting CI

The main CI workflow runs the test suite across its declared Python and OS
Expand Down
2 changes: 1 addition & 1 deletion docs/source/ci-cd.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Before opening a change, run:
python -m pytest
ruff check src tests
black --check src tests
mypy src --ignore-missing-imports
mypy src stubs

Documentation changes should also build with warnings as errors:

Expand Down
2 changes: 1 addition & 1 deletion docs/source/contributing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ by the repository:
python -m pip install -e ".[dev,docs]"
ruff check src tests
black --check src tests
mypy src --ignore-missing-imports
mypy src stubs
python -m pytest
python -m sphinx -E -W --keep-going -b html docs/source docs/source/_build/html

Expand Down
19 changes: 10 additions & 9 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -129,17 +129,18 @@ target-version = "py310"
[tool.ruff.lint]
select = ["E", "F", "I", "N", "W", "UP"]

[tool.ruff.lint.per-file-ignores]
# The declarations in stubs/ carry the libraries' own names.
"stubs/**" = ["N801", "N802"]

[tool.mypy]
python_version = "3.10"
follow_imports_for_stubs = true
ignore_missing_imports = true
# Optional array libraries are not importable by the checker, so what their
# modules return is untyped; those values are admitted by Array registration
# at runtime (see arraybridge.libraries).
warn_return_any = false
ignore_missing_imports = false
# The optional array libraries are read from the declarations in stubs/, which
# list what arraybridge calls and come before any installed copy, so the result
# does not depend on what is installed where mypy runs.
mypy_path = "$MYPY_CONFIG_FILE_DIR/stubs"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = false # Too strict for scientific code

[[tool.mypy.overrides]]
module = "numpy.*"
follow_imports = "skip"
Loading
Loading