diff --git a/docs/superpowers/plans/2026-09-25-src-multi-gpu.md b/docs/superpowers/plans/2026-09-25-src-multi-gpu.md new file mode 100644 index 0000000..a22a7ba --- /dev/null +++ b/docs/superpowers/plans/2026-09-25-src-multi-gpu.md @@ -0,0 +1,503 @@ +# SRC Sweep over the GPUs of a Node (Phase 2): Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Run one `src` sweep on the GPUs of a node, one process per GPU, with the sketch index split among them, so that the Phase 1 reference problem finishes close to `G` times faster on `G` GPUs. + +**Architecture:** A small `Communicator` interface in `_comm.py` (single process, `mpi4py` on host arrays, NCCL on CuPy arrays) carries the few collectives the sweep needs. `src` builds the communicator from `Resources(comm=...)`, agrees on the call and the seed, and hands the output to a sink (rank 0 in memory, or `.npy` files and memmaps on every rank). The Phase 1 driver keeps one copy of the maths: each rank owns a cyclic subset of the sketch columns, and the right-to-left pass all-gathers the sketch columns and the rows of the projected environment at every site. The planner plans each rank's share. + +**Tech Stack:** Python 3.11-3.14, NumPy and CuPy (through `src_method.utils._backend`), `opt_einsum`, `structlog`, `mpi4py>=4` (new `mpi` extra), NCCL through `cupy.cuda.nccl` (`nvidia-nccl-cu12` in the `gpu-nvidia` extra), `pytest`, `cyclopts` (benchmark), `uv`, `ruff` through `prek`, GitButler (`but`). + +**Spec:** `docs/superpowers/specs/2026-09-25-src-multi-gpu-design.md` (read it first; this plan argues from it). Phase 1 context: `docs/superpowers/specs/2026-09-25-src-out-of-core-design.md`. + +## How to read this plan + +This plan was written under a rule of the design phase: no code in full. Every task +gives the files, the exact interfaces (signatures, types, error messages), the +behaviour in prose or pseudocode, and the tests to write, each with its setup and +its assertions. The executor writes the code, test first, and runs the commands +given. Where a detail is left to the executor, the plan says so. + +## Global Constraints + +- Work on branch `feat/src-multi-gpu`, stacked on `feat/src-out-of-core`; the spec is committed there. Commit with `but commit -b feat/src-multi-gpu -m ... ` (IDs from `but diff`), never `git add`/`git commit`, and never the untracked `.codegraph/`. +- `from __future__ import annotations` at the top of every module under `src/`; type hints everywhere; Google-style docstrings without types on every public function, class and module; `ruff` is the source of truth for style. +- Backend-specific calls (CuPy, NCCL, device selection) live in `src_method.utils._backend` or in `_comm.py`; the algorithms get `xp` and a `Communicator`. `mpi4py` and `cupy.cuda.nccl` are imported lazily, only when a communicator is given: a single-process run imports neither. +- Log with `structlog`, never `print`, and never `warnings.warn` for run-time advice (`filterwarnings = ["error"]` turns warnings into test failures). +- `src`, `apply`, `compress` stay pure: never mutate inputs. +- Commits: `(): `, ending with `Assisted-by: Pi:claude-opus-5-5`, no `Co-authored-by`. +- Before pushing: `uv run prek run --all-files` and `uv run pytest` pass. +- Decisions of the spec, verbatim: one process per GPU (SPMD) launched with `srun` or `mpirun`; NCCL through `cupy.cuda.nccl` with `mpi4py` for bootstrap; `Resources(comm=...)`, nothing collective without it; output on rank 0 in memory, or `.npy` files and memmaps on every rank with `Resources(output_dir=...)`; large inputs file-backed; success is strong scaling (at least 80% parallel efficiency at 8 GPUs on 8xA100 and 8xH100 at the reference size, at least 90% on 2xA100). +- Without a communicator, or with one of size one, the sweep is exactly Phase 1: the existing tests must pass unchanged. + +## Refinements of the spec + +Settled while planning; the executor should not undo them. + +1. **`Communicator.abort(code)`** joins the interface: the driver calls it when an exception escapes during the sweep on more than one rank. `SingleComm.abort` is never called. +2. **The truncation rank is agreed through a callback**: `truncated_qr(matrix, cutoff, xp, agree_rank=None)` computes its local rank from the singular values of `R` and passes it through `agree_rank` (rank 0's value, broadcast) before truncating. The spec's "optional `rank` argument" cannot work, because the rank is only known inside the function. +3. **Output sinks and `unpad_site`**: the output goes through a small sink object, and each core is unpadded as it is produced, with a new `unpad_site` that mirrors `pad_site`. +4. **Large in-memory inputs are logged, not warned about**: when a distributed call on more than one rank per node gets an in-memory `np.ndarray` layer (not a memmap) above 1 GiB, `src` logs a warning through `structlog` that every rank holds a copy. +5. **CI uses the system MPICH** on the Ubuntu runner (`apt-get install mpich`) with the `mpi4py` wheel from PyPI; the MPI tests skip wherever `mpiexec` or a working `mpi4py` is missing. + +## File map + +| File | Status | Responsibility | +|---|---|---| +| `src/src_method/_comm.py` | create | `Communicator`, `SingleComm`, `MpiHostComm`, `NcclComm`, `make_communicator`; block and share helpers; `agree`, `collectively` | +| `src/src_method/_output.py` | create | output sinks: memory, directory, none | +| `src/src_method/_tensor_train.py` | modify | `unpad_site` | +| `src/src_method/utils/linalg.py` | modify | `truncated_qr(..., agree_rank=None)` | +| `src/src_method/utils/_backend.py` | modify | `select_device(local_rank, xp)`, `nccl_module(xp)` | +| `src/src_method/_plan.py` | modify | `Resources.comm`, `Resources.output_dir`; per-rank shares in `resolve_budgets`; `make_plan(..., columns=, rows=, gather=, holds_output=)` | +| `src/src_method/_sweep.py` | modify | column ownership, the two all-gathers, rank-0 output, collective error handling | +| `src/src_method/stack.py` | modify | communicator, fingerprint, seed, device, sinks | +| `pyproject.toml`, `uv.lock`, `flake.nix`, `.github/workflows/test.yml` | modify | `mpi` extra, NCCL wheel, MPICH in the dev shell and in CI | +| `tests/conftest.py` | create | `mpirun` fixture | +| `tests/mpi_scripts/*.py` | create | scripts run under `mpiexec` | +| `tests/test_comm.py`, `tests/test_output.py` | create | unit and MPI tests | +| `tests/test_plan.py`, `tests/test_stack.py`, `tests/test_tensor_train.py`, `tests/test_backend.py`, `tests/test_gpu_backend.py`, `tests/test_linalg.py` | modify / create | planner, integration, helpers, GPU | +| `benches/large/bench_large.py`, `benches/large/README.md` | modify | MPI mode, `scaling` and `efficiency` commands | +| `docs/large-problems.md`, `README.md`, `docs/developer-guide/dependencies.md`, `docs/developer-guide/testing.md` | modify | documentation | + +--- + +### Task 1: Work splitting and block helpers, and the single-process communicator + +**Files:** +- Create: `src/src_method/_comm.py` +- Test: `tests/test_comm.py` (create) + +**Interfaces (produced):** + +```python +class Communicator(Protocol): + rank: int + size: int + local_rank: int + local_size: int + def allgather(self, local: NDArray, axis: int) -> NDArray: ... + def allgather_objects(self, value: object) -> list[object]: ... + def bcast_int(self, value: int, root: int = 0) -> int: ... + def barrier(self) -> None: ... + def abort(self, code: int) -> None: ... + +class SingleComm: # rank 0 of 1, local rank 0 of 1 + ... # allgather returns `local` itself; allgather_objects -> [value]; + # bcast_int -> value; barrier no-op; abort raises SystemExit(code) + +def owned_columns(n: int, rank: int, size: int) -> slice # slice(rank, n, size) +def row_block(n: int, rank: int, size: int) -> slice # contiguous, sizes differ by <= 1, + # lower ranks take the larger blocks +def pad_blocks(block: NDArray, axis: int, rows: int) -> NDArray + # move `axis` to the front, make contiguous, pad with zeros to `rows` along it +def join_blocks(gathered: NDArray, counts: Sequence[int], axis: int) -> NDArray + # gathered: (size, rows_max, *rest), padded blocks in rank order; + # take counts[r] rows of block r, concatenate, move the axis back +def agree(comm: Communicator, fn: Callable[[], T]) -> T +def collectively(comm: Communicator, fn: Callable[[], T]) -> T +``` + +Behaviour: + +- `agree` runs `fn` on every rank, then all-gathers `None` or `(type name, message)` of the exception it raised. If any rank failed, every rank raises an exception of the same type (one of `ValueError`, `MemoryError`, `TypeError`, otherwise `RuntimeError`) with the message of the lowest failing rank, prefixed `"Rank {r}: "`. Without failures it returns `fn()`'s result. With `SingleComm` it is `fn()`. +- `collectively` runs `fn`; if an exception escapes and `comm.size > 1`, it logs it with `logger.exception("SRC failed; aborting", rank=comm.rank)` and calls `comm.abort(1)`; with one rank it re-raises. + +Tests to write (`tests/test_comm.py`): + +- [ ] **Step 1: Write the failing tests** + - `owned_columns`: for `n in (1, 7, 8, 2000)` and `size in (1, 2, 3, 8)`, the ranges of all ranks partition `range(n)` exactly once; rank `r` gets `ceil((n - r) / size)` columns. + - `row_block`: same partition property; blocks are contiguous and in rank order; block sizes differ by at most one. + - `pad_blocks` then `join_blocks` round-trips random blocks of uneven sizes along axis 0 and axis 3 of a rank-4 array, float64 and complex128: joining the padded blocks of three ranks equals `np.concatenate(blocks, axis)`. + - `SingleComm`: `allgather(x, axis)` is `x`; `allgather_objects(v) == [v]`; `bcast_int(5) == 5`; `rank, size, local_rank, local_size == 0, 1, 0, 1`. + - `agree` with `SingleComm`: returns the value; re-raises a `MemoryError` from `fn` unchanged in type. + - `collectively` with `SingleComm`: re-raises. +- [ ] **Step 2: Run** `uv run pytest tests/test_comm.py -o log_cli=false`; expected: `ModuleNotFoundError: No module named 'src_method._comm'`. +- [ ] **Step 3: Implement** `_comm.py` with the interfaces above. Keep `mpi4py` and CuPy imports out of this task. +- [ ] **Step 4: Run** the tests; expected: all pass. +- [ ] **Step 5: Lint** `uv run ruff format src/src_method/_comm.py tests/test_comm.py && uv run ruff check src tests`. +- [ ] **Step 6: Commit** `feat(comm): ✨ work splitting, block helpers and the single-process communicator`. + +--- + +### Task 2: MPI on host arrays, the `mpi` extra and the MPI test harness + +**Files:** +- Modify: `src/src_method/_comm.py` (add `MpiHostComm`, `make_communicator`) +- Modify: `pyproject.toml` (extra `mpi = ["mpi4py>=4"]`), `uv.lock` (`uv lock`), `flake.nix` (add `pkgs.mpich` to the dev shell packages and its `lib` to `LD_LIBRARY_PATH`), `.github/workflows/test.yml` (Ubuntu runner: `sudo apt-get install -y mpich` before syncing, and `--extra mpi` in `uv sync`; the GPU runner is unchanged) +- Create: `tests/conftest.py`, `tests/mpi_scripts/collectives.py` +- Test: `tests/test_comm.py` (append) + +**Interfaces (produced):** + +```python +class MpiHostComm: + def __init__(self, comm: Any) -> None: ... # an mpi4py Comm + # rank/size from comm; local_rank/local_size from comm.Split_type(MPI.COMM_TYPE_SHARED) + # allgather: moveaxis + contiguous uint8 view, Allgatherv with byte counts and + # displacements from an allgather of the block lengths, then join_blocks + # allgather_objects: comm.allgather; bcast_int: comm.bcast; barrier: comm.Barrier + # abort: comm.Abort(code) + +def make_communicator(comm: Any | None, xp: ModuleType) -> Communicator: + # None or comm.Get_size() == 1 -> SingleComm() + # host backend -> MpiHostComm(comm); GPU backend -> NcclComm(comm, xp) (Task 3) +``` + +Test harness (`tests/conftest.py`): + +```python +@pytest.fixture +def mpirun(tmp_path) -> Callable[..., MpiResult]: + # skip if shutil.which("mpiexec") is None, or if + # `python -c "from mpi4py import MPI"` fails in a subprocess + # run(script: str, n: int, *args: str, timeout: float = 120) -> MpiResult + # command: mpiexec [--oversubscribe if Open MPI] -n n sys.executable + # tests/mpi_scripts/