diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml
new file mode 100644
index 0000000..938aba5
--- /dev/null
+++ b/.github/workflows/benchmark.yml
@@ -0,0 +1,137 @@
+# Comparative benchmark: adata-cli against anndata and scanpy.
+#
+# Report-only, by design. Wall time on a shared runner is too noisy to gate a
+# release on, and a benchmark that can block a publish stops being run. What
+# *does* gate merges is tests/test_performance.py, which counts operations
+# rather than seconds and lives in the ordinary test job.
+#
+# Its own file rather than a job in publish.yml: at the `ci` tier this takes
+# the better part of an hour, and hanging that off the release graph would
+# either delay the PyPI publish or paint the release run red for a report.
+name: Benchmark
+
+on:
+ push:
+ tags: ["*"]
+ workflow_dispatch:
+ inputs:
+ tier:
+ description: Input size
+ type: choice
+ options: [smoke, ci, large]
+ default: ci
+ publish:
+ description: Commit the results to docs/ on main
+ type: boolean
+ default: false
+
+concurrency:
+ group: benchmark-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ benchmark:
+ name: adata-cli vs anndata/scanpy
+ runs-on: ubuntu-latest
+ timeout-minutes: 90
+ # Job level, not step level: a report must never mark a release red.
+ continue-on-error: true
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - uses: astral-sh/setup-uv@v3
+ with:
+ enable-cache: true
+
+ # The checked-out source, not the published wheel. Benchmarking the
+ # wheel would mean waiting on publish-pypi and then on index
+ # propagation, for a measurement that would come out the same.
+ - name: Install adata-cli
+ run: uv sync --extra dev --frozen
+
+ - name: Show free space
+ run: df -h /
+
+ - name: Run
+ run: |
+ uv run python -m benchmarks.run \
+ --tier "${{ inputs.tier || 'ci' }}" \
+ --out results.json \
+ --work "$RUNNER_TEMP/bench"
+
+ - name: Render
+ run: |
+ uv run python -m benchmarks.report results.json --out summary.md
+ cat summary.md >> "$GITHUB_STEP_SUMMARY"
+
+ - uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: benchmark-${{ github.ref_name }}
+ path: |
+ results.json
+ summary.md
+ retention-days: 90
+
+ # docs/ is the durable home: artifacts expire, and one absolute number
+ # with nothing to compare it against says very little. The page is
+ # already served by Pages from docs/, so this needs no extra machinery.
+ - name: Publish to docs
+ if: github.ref_type == 'tag' || inputs.publish
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ # A tag build is on a detached HEAD; the page belongs on main.
+ git fetch origin main
+ git checkout -B main origin/main
+ uv run python -m benchmarks.report results.json --docs docs --publish
+ git add docs/BENCHMARKS.md docs/benchmarks
+ if git diff --cached --quiet; then
+ echo "nothing to publish"; exit 0
+ fi
+ git commit -m "Benchmark results for ${{ github.ref_name }} [skip ci]"
+ # Another job may have landed on main in the meantime.
+ for attempt in 1 2 3; do
+ if git push origin main; then exit 0; fi
+ git pull --rebase origin main
+ done
+ echo "could not push benchmark results"; exit 1
+
+ # Best effort, and last: releases here are cut by hand, so this job may
+ # well run before one exists. The docs page is authoritative either way.
+ - name: Append to the release notes
+ if: github.ref_type == 'tag'
+ continue-on-error: true
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ tag="${{ github.ref_name }}"
+ gh release view "$tag" >/dev/null 2>&1 || {
+ echo "no release for $tag yet; skipping"; exit 0
+ }
+ gh release view "$tag" --json body -q .body > body.md
+ # Idempotent: replace any block this job wrote before.
+ python - <<'PY'
+ import pathlib, re
+ body = pathlib.Path("body.md").read_text()
+ summary = pathlib.Path("summary.md").read_text()
+ block = (
+ "\n\n"
+ "Benchmark vs anndata/scanpy
\n\n"
+ f"{summary}\n \n\n"
+ )
+ pattern = re.compile(
+ r".*?", re.S
+ )
+ body = (
+ pattern.sub(block, body)
+ if pattern.search(body)
+ else body.rstrip() + "\n\n" + block + "\n"
+ )
+ pathlib.Path("body.md").write_text(body)
+ PY
+ gh release edit "$tag" --notes-file body.md
diff --git a/.gitignore b/.gitignore
index fb49bae..8626410 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,9 @@ htmlcov/
.pytest_cache/
pytest-results*.xml
compat-results.xml
+
+# Benchmark working directory and results -- the defaults of
+# `python -m benchmarks.run`, both written into the repo root.
+.bench/
+results.json
+summary.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2a129b5..d043cfc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,117 @@
Notable changes to `adata-cli`. Versions are `MAJOR.MINOR.PATCH`; tags carry no
`v` prefix.
+## 0.6.0
+
+Adds `adata convert`, and fixes four quadratic paths that made `concat` and
+`split` appear to hang on real data. Three of the four were found by a new
+suite of complexity guards, which now run on every pull request; the first
+was reported from a pipeline that had to kill twelve tasks after 98 minutes.
+
+### Added
+
+- **`adata convert` changes a matrix's dtype, layout or density on disk**
+ ([#13](https://github.com/cellgeni/adata-cli/issues/13)). Counts stored as
+ float64 halve with `--dtype float32`; `--indices-dtype int32` halves the
+ index arrays of a matrix small enough to address that way; `--layout
+ csr|csc` transposes between the sparse encodings; `--layout dense|sparse`
+ changes the density. Works on `X`, any layer, `raw/X` or any 2-D array by
+ path, and on all of them at once with `--all`.
+
+ A cast that would not round-trip is refused before anything is written --
+ every value is cast and cast back, because whether float64 counts survive
+ float32 depends on the counts, not on the dtypes. So is a densification
+ that would inflate the store beyond four times its size. `--force`
+ overrides either, and both messages say what they measured.
+
+ Transposing streams by default, holding one bucket of nonzeros rather than
+ the matrix, so it works on files too large to load; `--in-memory` is
+ faster when the matrix fits. Buckets are balanced by nonzero count rather
+ than by coordinate range, because a single-cell matrix is skewed -- a few
+ genes carry most of the counts -- and equal-width bounds put most of one
+ in a single bucket.
+
+ `--indices-dtype` is checked against both what `indices` must address and
+ what `indptr` must reach, which differ: a narrow matrix with more than
+ 2^31 nonzeros needs int64 offsets over int32 coordinates. Both are
+ preserved from the source when not specified.
+
+- **`concat` now names the command to run** when inputs disagree about a
+ matrix encoding. The check itself is not new, but nothing tested it and it
+ could not suggest a fix, because there was none.
+
+- **Complexity guards in the test suite** (`tests/test_performance.py`).
+ Cost regressions now fail at merge time. They count operations rather than
+ seconds -- h5py and zarr reads, Zarr store traffic, Python allocation and
+ executed lines -- and assert that successive increments grow no faster than
+ linearly, so nothing here can fail because a CI runner was busy. See
+ [docs/TESTING.md](docs/TESTING.md#performance). Every subcommand is covered:
+ `ls`, `view`, `create`, all five `export` and all five `import` variants,
+ `split` on both axes, and the `concat` options nothing else reached.
+ Two claims are now enforced rather than described -- `view` and `ls` read
+ **zero** data elements at any store size, and streaming stays far below the
+ input curve at a fixed `--chunk`.
+
+- **A comparative benchmark** (`benchmarks/`), run on every tag against
+ anndata and against scanpy where scanpy has a real equivalent. Reports peak
+ RSS, wall time and output size; publishes to
+ [docs/BENCHMARKS.md](docs/BENCHMARKS.md) and the release notes. Report-only
+ -- it never fails a build. Fifteen cases, covering every command with a real
+ baseline, including `h5ls -r` for `ls` and the rows where adata-cli is the
+ slower of the two.
+
+- **`--merge drop` and `--uns-merge drop` are accepted.** `drop` was already
+ the documented default behaviour but was rejected as a value, so a config
+ could not state it explicitly.
+
+### Fixed
+
+- **`concat --merge` never finished on a real store.** Aligning a var column
+ onto the target index re-read the whole column from disk once per target
+ variable, so the cost was quadratic: at 36,601 variables a merge that should
+ take a fraction of a second ran for hours at 100% CPU with the output file
+ never growing past its header. Reported against 0.5.1 (REQ-71798), where 12
+ of 13 pipeline tasks had to be killed after 98 minutes. The column is now
+ read once per input, and `--merge first` / `--merge only`, which decide on
+ presence alone, read no column values at all.
+
+- **`concat` was quadratic in the number of categories** in an obs column.
+ Category merging probed a list rather than a dict: 2,096,128 string
+ comparisons to union 1,024 categories, and around 5e9 for a 100k-category
+ column. Found by the new guards.
+
+- **`split --by` was quadratic**, O(n_rows x n_groups). `group_indices` grouped
+ rows with `np.nonzero(values == label)` inside a loop over distinct labels,
+ rescanning each chunk once per label: at 4,096 rows, 16,384 elements scanned
+ for 4 groups and 1,048,576 for 256. A million cells split by a thousand
+ samples is ~10^9 comparisons. One `np.unique` pass per chunk makes it flat
+ in the group count. Found by the new guards; order of first appearance,
+ which names the output files, is unchanged.
+
+- **`concat` built a Python object per row** for nullable and string obs
+ columns, then walked the list twice more. Filling a typed buffer by slice
+ removes three full passes over every such column.
+
+- **Copying variable-length strings ignored its own read budget.** The width
+ of a vlen element was assumed to be 64 bytes, because h5py reports the
+ itemsize of a pointer, so the step was the same 524,288 elements whatever
+ the data held: 2 GiB per read at 4 KiB elements against a stated 32 MiB
+ budget, and for any array shorter than that step, the whole array in one
+ go. Copying 200,000 strings of 4 KiB peaked at 827 MB. The width is now
+ sampled from the first 256 elements. Reported by an automated review on
+ PR #14 and confirmed by measurement; `uns` can hold arbitrary text, so this
+ was not a width the layer could assume.
+
+- **Peak RSS in the benchmark was floored by the runner's own memory on Linux.**
+ A forked child inherits its parent's resident pages and `execve` folds that
+ into the `maxrss` the kernel reports, so every contender would have measured
+ at least what `benchmarks/run.py` used to build the fixtures — around
+ 200 MB — and the tables would have read "everything costs about the same".
+ Commands are now forked from a small shim: with a 330 MB parent, a no-op
+ child goes from 326 MB to 8 MB. Caught by `test_benchmark_harness.py`, which
+ exists for exactly this. The published figures were measured on macOS, which
+ resets the high-water mark at exec, and are unchanged.
+
## 0.5.1
Makes the container image usable from Nextflow, and stops `copy_dataset`
diff --git a/README.md b/README.md
index 1b55ad9..0e75618 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@ A command-line tool for exploring huge AnnData stores (`.h5ad` and `.zarr`) with
## Features
-- Streaming access to very large `.h5ad` and `.zarr` stores
+- Streaming access to very large `.h5ad` and `.zarr` stores — see [the benchmarks](docs/BENCHMARKS.md) for what that costs in practice, including where loading the file outright is faster
- Auto-detects `.h5ad` files vs `.zarr` directories
- Chunked processing for dense and sparse matrices (CSR/CSC)
- Reads every AnnData on-disk layout, from 0.7.x through the current spec, and always writes the current one
@@ -56,6 +56,7 @@ Run help at any level (e.g. `adata --help`, `adata export --help`).
- `subset` – stream and write a filtered copy, selected by obs/var name lists (`--obs`/`--var`) or by expression (`--obs-query`/`--var-query`).
- `split` – write one store per distinct value of an annotation column, with a CSV manifest.
- `concat` – concatenate stores along the obs axis, with `--join inner|outer` and merge strategies for var and uns.
+- `convert` – change a matrix's dtype, layout (CSR/CSC/dense) or density, streaming; refuses a lossy cast or a large size increase unless forced.
- `export` – extract data from a store; subcommands: `dataframe` (any dataframe group to CSV), `array` (dense to `.npy`), `sparse` (CSR/CSC to `.mtx`), `dict` (JSON), `image` (PNG). Results go to stdout when no `--output` is given.
- `import` – write new data into a store at any path; subcommands: `dataframe` (CSV), `array` (`.npy`), `sparse` (`.mtx`), `dict` (JSON), `image` (PNG/JPEG/TIFF).
@@ -78,12 +79,26 @@ adata split data.h5ad --by sample -o per_sample/
adata concat per_sample/*.h5ad -o merged.h5ad --join outer --label sample
```
+### Shrinking a store, and making encodings agree
+
+```bash
+adata convert data.h5ad X --inplace --dtype float32
+adata convert data.h5ad --all -o small.h5ad --dtype float32 --indices-dtype int32
+adata convert data.h5ad X -o csc.h5ad --layout csc
+```
+
+Counts written as float64 halve with no loss — and `convert` proves that
+before it writes, by casting every value and casting it back. `concat`
+refuses inputs whose matrices disagree about CSR versus CSC; `--layout` is
+how you make them agree.
+
## Documentation
- [Get started](docs/GET_STARTED.md) — a short tutorial
- [Command reference](docs/COMMANDS.md) — every command and flag
- [Element spec: HDF5](docs/ELEMENTS_h5ad.md) / [Zarr](docs/ELEMENTS_zarr.md) — the on-disk format, and what this tool does with it
-- [Testing](docs/TESTING.md) — how the suite is organised, and how compatibility is verified against six anndata releases
+- [Testing](docs/TESTING.md) — how the suite is organised, how compatibility is verified against six anndata releases, and the complexity guards that keep cost regressions out
+- [Benchmarks](docs/BENCHMARKS.md) — peak memory and wall time against anndata and scanpy, remeasured on every release
- [Changelog](CHANGELOG.md)
## Docker
diff --git a/benchmarks/__init__.py b/benchmarks/__init__.py
new file mode 100644
index 0000000..f87cdbe
--- /dev/null
+++ b/benchmarks/__init__.py
@@ -0,0 +1,10 @@
+"""Comparative benchmark for adata-cli, against anndata and scanpy.
+
+Report-only, and deliberately separate from `tests/`. The tests measure
+operation counts and gate merges; this measures wall time and peak RSS, runs
+on tags, and never fails a build -- timing on a shared runner is too noisy to
+gate on.
+
+ uv run python -m benchmarks.run --tier smoke
+ uv run python -m benchmarks.report results.json
+"""
diff --git a/benchmarks/_measure.py b/benchmarks/_measure.py
new file mode 100644
index 0000000..072f196
--- /dev/null
+++ b/benchmarks/_measure.py
@@ -0,0 +1,323 @@
+"""Run one command as a child process and report what it cost.
+
+Peak RSS is the number that matters here. adata-cli exists so that memory is
+set by chunk size rather than by input size, and a comparison reporting only
+wall time would misrepresent it -- for anything that fits in RAM, loading the
+whole thing is usually faster.
+
+Two things have to be right for the number to mean anything, and both were
+wrong at some point.
+
+**Use `os.wait4`, not `resource.getrusage`.** `RUSAGE_CHILDREN` is a running
+maximum over every child the process has ever reaped: run a 400 MB case and
+then a 17 MB one and it still reports 400 MB. `wait4` returns rusage for one
+specific child.
+
+**Fork the child from a small process.** On Linux a forked child inherits its
+parent's resident pages, and `execve` folds that pre-exec high-water mark into
+the accumulated `maxrss` that `wait4` reports. So a child of a fat parent can
+never appear small. Measured under python:3.12-slim:
+
+ parent 14.7 MB -> no-op child 11.8 MB
+ parent 329.6 MB -> no-op child 326.4 MB <- the parent's RSS, not the child's
+ parent 329.6 MB -> via shim 8.1 MB
+
+macOS resets the high-water mark at exec and shows none of this, which is why
+it went unnoticed locally and failed on CI. It matters because `run.py`
+imports anndata, pandas and numpy to build fixtures in the same process that
+measures, so every contender would have been floored at ~200 MB and the
+tables would have read "everything costs about the same".
+
+`posix_spawn` does not help -- the middle row above is 329.5 MB that way too.
+The fix is the shim: `measure()` re-invokes this file as a subprocess, and
+that freshly-exec'd interpreter, about 8 MB, is what forks the command being
+measured.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import resource
+import shutil
+import subprocess
+import sys
+import tempfile
+import time
+from dataclasses import asdict, dataclass
+from pathlib import Path
+from typing import Dict, List, Optional, Sequence
+
+#: Address-space ceiling for every child, in bytes.
+#:
+#: Cases where the in-memory baseline cannot cope are the whole point of the
+#: comparison, but an uncontained OOM on a hosted runner kills the runner
+#: agent and the job ends with no report at all. With a ceiling the baseline
+#: dies with a MemoryError and a non-zero exit, which is a *result* -- recorded
+#: as `oom` at a limit we can state.
+#:
+#: Linux only -- macOS refuses RLIMIT_AS, so a local run is unbounded and an
+#: `oom` row cannot be reproduced there.
+DEFAULT_MEMORY_LIMIT = 12 * 1024**3
+
+#: `ru_maxrss` is KiB on Linux and bytes on macOS. Nothing in the man pages
+#: says so; it is simply what each kernel does.
+_MAXRSS_SCALE = 1024 if sys.platform.startswith("linux") else 1
+
+#: How often to check whether the child has exited. Fine enough that it adds
+#: no meaningful error to a run measured in seconds.
+POLL_INTERVAL_S = 0.01
+
+
+@dataclass
+class Measurement:
+ """What one run of one contender cost."""
+
+ wall_s: float
+ maxrss_bytes: int
+ exit_code: int
+ status: str # "ok" | "failed" | "oom" | "timeout"
+ output_bytes: int = 0
+ output_files: int = 0
+ stderr_tail: str = ""
+
+ def as_dict(self) -> Dict:
+ return asdict(self)
+
+
+def _tree_size(path: Path) -> tuple:
+ """Apparent bytes and file count for a file or a directory.
+
+ Zarr stores are directories of many small chunks. Reporting only `du`
+ would make them look several times larger than the data they hold, since
+ every chunk rounds up to a filesystem block -- so the file count is
+ reported alongside, and both go in the table.
+ """
+ if not path.exists():
+ return 0, 0
+ if path.is_file():
+ return path.stat().st_size, 1
+ total = 0
+ count = 0
+ for child in path.rglob("*"):
+ if child.is_file():
+ total += child.stat().st_size
+ count += 1
+ return total, count
+
+
+def drop_page_cache() -> bool:
+ """Try to drop the page cache, so a read is not served from RAM.
+
+ A fixture written seconds ago is entirely in cache, which systematically
+ understates how much streaming helps. GitHub-hosted runners have
+ passwordless sudo, so this usually works there and usually does not
+ locally; the report says which happened rather than pretending.
+ """
+ try:
+ subprocess.run(
+ ["sudo", "-n", "sh", "-c", "sync; echo 3 > /proc/sys/vm/drop_caches"],
+ check=True,
+ capture_output=True,
+ timeout=30,
+ )
+ return True
+ except Exception:
+ return False
+
+
+def measure(
+ command: Sequence[str],
+ *,
+ output: Optional[Path] = None,
+ timeout_s: float = 900.0,
+ memory_limit: Optional[int] = DEFAULT_MEMORY_LIMIT,
+ env: Optional[Dict[str, str]] = None,
+ cwd: Optional[Path] = None,
+) -> Measurement:
+ """Run `command` from a small shim, and report what it cost.
+
+ The shim is this same file, re-invoked. It exists so that the process
+ which forks `command` is a bare interpreter rather than whatever imported
+ anndata -- see the module docstring for the measurement it fixes.
+ """
+ # Checked here rather than in the shim: a stale output would be sized and
+ # reported as this run's work, which is a bug in the caller and should be
+ # loud rather than turned into a "failed" row.
+ if output is not None and output.exists():
+ raise FileExistsError(f"{output} exists; the harness never overwrites.")
+
+ argv = [sys.executable, str(Path(__file__).resolve()), "--timeout", str(timeout_s)]
+ if output is not None:
+ argv += ["--output", str(output)]
+ if memory_limit is None:
+ argv += ["--no-memory-limit"]
+ else:
+ argv += ["--memory-limit", str(memory_limit)]
+ if cwd is not None:
+ argv += ["--cwd", str(cwd)]
+ for key, value in (env or {}).items():
+ argv += ["--env", f"{key}={value}"]
+ argv += ["--", *command]
+
+ done = subprocess.run(argv, capture_output=True, text=True)
+ try:
+ return Measurement(**json.loads(done.stdout))
+ except (json.JSONDecodeError, TypeError, ValueError):
+ # The shim itself failed. Report it rather than crashing the run, and
+ # keep enough of its output to diagnose.
+ return Measurement(
+ wall_s=0.0,
+ maxrss_bytes=0,
+ exit_code=done.returncode,
+ status="failed",
+ stderr_tail="measurement shim failed:\n"
+ + "\n".join((done.stderr or done.stdout).strip().splitlines()[-6:]),
+ )
+
+
+def _measure_here(
+ command: Sequence[str],
+ *,
+ output: Optional[Path] = None,
+ timeout_s: float = 900.0,
+ memory_limit: Optional[int] = DEFAULT_MEMORY_LIMIT,
+ env: Optional[Dict[str, str]] = None,
+ cwd: Optional[Path] = None,
+) -> Measurement:
+ """Run `command` in this process's own child. Only the shim calls this."""
+
+ def limit() -> None: # pragma: no cover - runs in the forked child
+ if memory_limit is None:
+ return
+ try:
+ resource.setrlimit(resource.RLIMIT_AS, (memory_limit, memory_limit))
+ except (ValueError, OSError):
+ # macOS refuses RLIMIT_AS outright. The ceiling only has to hold
+ # on the runner, which is Linux; raising here would abort the
+ # spawn and lose the measurement entirely.
+ pass
+
+ if output is not None and output.exists():
+ raise FileExistsError(f"{output} exists; the harness never overwrites.")
+
+ full_env = {**os.environ, **(env or {})}
+
+ if shutil.which(command[0]) is None and not Path(command[0]).exists():
+ return Measurement(
+ wall_s=0.0, maxrss_bytes=0, exit_code=127, status="n/a",
+ stderr_tail=f"{command[0]} is not installed",
+ )
+
+ # Output goes to temporary files, not pipes. A pipe would have to be
+ # drained with communicate(), and communicate() reaps the child -- after
+ # which wait4 has nothing to report and the per-child rusage is lost.
+ with tempfile.TemporaryFile("w+") as out_f, tempfile.TemporaryFile("w+") as err_f:
+ started = time.perf_counter()
+ process = subprocess.Popen(
+ list(command),
+ stdout=out_f,
+ stderr=err_f,
+ preexec_fn=limit,
+ env=full_env,
+ cwd=str(cwd) if cwd else None,
+ text=True,
+ )
+
+ status = "ok"
+ deadline = started + timeout_s
+ while True:
+ pid, wait_status, usage = os.wait4(process.pid, os.WNOHANG)
+ if pid != 0:
+ process.returncode = (
+ -os.WTERMSIG(wait_status)
+ if os.WIFSIGNALED(wait_status)
+ else os.WEXITSTATUS(wait_status)
+ )
+ maxrss = usage.ru_maxrss
+ exit_code = process.returncode
+ break
+ if time.perf_counter() > deadline:
+ process.kill()
+ _, wait_status, usage = os.wait4(process.pid, 0)
+ process.returncode = -9
+ maxrss = usage.ru_maxrss
+ exit_code = -9
+ status = "timeout"
+ break
+ time.sleep(POLL_INTERVAL_S)
+
+ err_f.seek(0)
+ stderr = err_f.read()
+
+ wall_s = time.perf_counter() - started
+ stderr = stderr or ""
+
+ if status == "ok" and exit_code != 0:
+ status = "oom" if _looks_like_oom(stderr) else "failed"
+
+ size, files = _tree_size(output) if output is not None else (0, 0)
+ return Measurement(
+ wall_s=round(wall_s, 3),
+ maxrss_bytes=int(maxrss) * _MAXRSS_SCALE,
+ exit_code=exit_code,
+ status=status,
+ output_bytes=size,
+ output_files=files,
+ stderr_tail="\n".join(stderr.strip().splitlines()[-6:]),
+ )
+
+
+def _looks_like_oom(stderr: str) -> bool:
+ """Did the child die against the address-space ceiling?
+
+ Under RLIMIT_AS an allocation failure surfaces as MemoryError, or as one
+ of numpy's or HDF5's own phrasings of the same thing.
+ """
+ markers = (
+ "MemoryError",
+ "Unable to allocate",
+ "bad_alloc",
+ "Cannot allocate memory",
+ "out of memory",
+ )
+ return any(m in stderr for m in markers)
+
+
+def main(argv: List[str]) -> int: # pragma: no cover - runs as the shim
+ """Measure the command after `--` and print one JSON object.
+
+ This is the shim `measure()` re-invokes; it is also usable by hand:
+
+ python benchmarks/_measure.py --timeout 60 -- adata view f.h5ad
+ """
+ parser = argparse.ArgumentParser(description="Measure one command.")
+ parser.add_argument("--output", type=Path)
+ parser.add_argument("--timeout", type=float, default=900.0)
+ parser.add_argument("--memory-limit", type=int, default=DEFAULT_MEMORY_LIMIT)
+ parser.add_argument("--no-memory-limit", action="store_true")
+ parser.add_argument("--cwd", type=Path)
+ parser.add_argument("--env", action="append", default=[])
+ parser.add_argument("command", nargs=argparse.REMAINDER)
+ args = parser.parse_args(argv)
+
+ command = args.command[1:] if args.command[:1] == ["--"] else args.command
+ if not command:
+ parser.error("no command given after --")
+
+ extra = dict(pair.split("=", 1) for pair in args.env)
+ result = _measure_here(
+ command,
+ output=args.output,
+ timeout_s=args.timeout,
+ memory_limit=None if args.no_memory_limit else args.memory_limit,
+ env=extra or None,
+ cwd=args.cwd,
+ )
+ print(json.dumps(result.as_dict()))
+ return 0
+
+
+if __name__ == "__main__": # pragma: no cover
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/benchmarks/cases.py b/benchmarks/cases.py
new file mode 100644
index 0000000..6f752b3
--- /dev/null
+++ b/benchmarks/cases.py
@@ -0,0 +1,573 @@
+"""What gets compared, and the rules that keep the comparison honest.
+
+The rules, because this is the part that decays fastest
+-------------------------------------------------------
+
+**Use the best idiom the baseline has, not the naive one.** Comparing
+`adata export dataframe obs` against a full `ad.read_h5ad()` is a strawman:
+anndata reads just `obs` cheaply via `read_elem` on the h5py group. The good
+idiom is the primary baseline. A naive full load may appear only as a clearly
+labelled second row, where the point is the gap between the two.
+
+**Pin compression on both sides.** adata-cli forwards the source's settings to
+its output; `write_h5ad` defaults to none. Left alone, adata-cli's output
+looks smaller for reasons unrelated to the tool.
+
+**Give the baseline everything it needs.** `concat_on_disk` imports `dask`
+to handle a dense element and fails outright without it, so the baseline
+environments install it. Measuring a library crippled by a missing optional
+dependency would be measuring our own setup.
+
+**`n/a` is a result.** `concat_on_disk` is CSR/CSC-oriented and its outer-join
+support has varied by version; scanpy has no streaming concat at all. Print
+what refused and why. An omitted row reads as an oversight; a stated `n/a`
+reads as a finding.
+
+**Startup is a floor, not noise.** The CLI takes 0.3-1 s to import typer, rich,
+h5py and zarr, and `import scanpy` takes 3-8 s. On a small tier that is the
+entire measurement, so every run includes a `--version` row to read the rest
+against.
+
+**Do not hide the rows where the baseline wins.** `_concat_csr` loops per row
+in Python; scipy's C `vstack` will very likely be several times faster in wall
+time at many times the memory. That trade *is* the argument for this tool.
+The table is "peak RSS against wall time", not a leaderboard.
+
+**Not every command has a baseline, and four are left out on purpose.**
+`export image`, `export dict`, `import image` and `import dict` have no
+library equivalent, so their rows would only ever read `n/a` and they would
+add runtime to every tag for nothing. They are covered by the complexity
+guards in `tests/test_performance.py` instead.
+
+**scanpy's filter functions are not our subset.** `sc.pp.filter_cells`
+*computes* `n_genes` by scanning X; `adata subset --obs-query` filters a
+column that must already exist. Head to head, scanpy looks slow for doing
+strictly more work. The like-for-like baseline is a plain boolean mask over a
+precomputed column, and scanpy appears in a separately labelled row.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Dict, List, Optional
+
+#: Written into every generated fixture, and forced on every baseline write.
+COMPRESSION = "lzf"
+
+
+@dataclass(frozen=True)
+class Contender:
+ """One way of doing the case: a command template, or a script."""
+
+ label: str
+ #: Python source run by the baseline interpreter. `None` for the CLI.
+ script: Optional[str] = None
+ #: argv for the CLI, templated with {input0}, {input1}, {output}.
+ argv: Optional[List[str]] = None
+ #: Set when the operation has no equivalent, explaining what is missing.
+ unsupported: Optional[str] = None
+ #: Which prebuilt environment runs `script`.
+ env: str = "anndata"
+
+
+@dataclass(frozen=True)
+class Case:
+ """One operation, and every way of performing it."""
+
+ name: str
+ question: str
+ contenders: List[Contender]
+ #: How many inputs the case needs.
+ inputs: int = 1
+ #: Extension of the output the case writes ("" for read-only cases).
+ output_suffix: str = ".h5ad"
+ #: Use the var-heavy shape rather than the tier's main shape.
+ var_heavy: bool = False
+ #: An auxiliary input the case needs, e.g. the CSV an import reads.
+ #: Built once from the first input and offered to CLI contenders as
+ #: `{sidecar_csv}` and to scripts as $BENCH_SIDECAR_CSV.
+ sidecar: Optional[str] = None
+ tags: List[str] = field(default_factory=list)
+
+
+_PRELUDE = f"""
+import sys, warnings
+warnings.filterwarnings("ignore")
+import anndata as ad, numpy as np, h5py
+IN = sys.argv[1:-1]
+OUT = sys.argv[-1]
+COMPRESSION = {COMPRESSION!r}
+"""
+
+
+def _py(body: str) -> str:
+ return _PRELUDE + body
+
+
+CASES: List[Case] = [
+ # -- startup floor ----------------------------------------------------
+ Case(
+ name="startup",
+ question="What does each contender cost before it does any work?",
+ output_suffix="",
+ contenders=[
+ Contender("adata-cli", argv=["adata", "--version"]),
+ Contender("anndata", script="import anndata"),
+ Contender("scanpy", script="import scanpy", env="scanpy"),
+ ],
+ tags=["floor"],
+ ),
+ # -- concat -----------------------------------------------------------
+ Case(
+ name="concat-inner",
+ question="Concatenate two stores on the obs axis, inner join.",
+ inputs=2,
+ contenders=[
+ Contender(
+ "adata-cli",
+ argv=["adata", "concat", "{input0}", "{input1}", "-o", "{output}"],
+ ),
+ Contender(
+ "anndata (concat_on_disk)",
+ script=_py(
+ "from anndata.experimental import concat_on_disk\n"
+ "concat_on_disk(IN, OUT, join='inner')\n"
+ ),
+ ),
+ Contender(
+ "anndata (in memory)",
+ script=_py(
+ "parts = [ad.read_h5ad(p) for p in IN]\n"
+ "ad.concat(parts, join='inner')"
+ ".write_h5ad(OUT, compression=COMPRESSION)\n"
+ ),
+ ),
+ Contender(
+ "scanpy",
+ unsupported="scanpy has no concat of its own; it re-exports anndata's",
+ ),
+ ],
+ ),
+ Case(
+ name="concat-merge-same",
+ question=(
+ "Concatenate carrying var columns forward -- the REQ-71798 case."
+ ),
+ inputs=2,
+ var_heavy=True,
+ contenders=[
+ Contender(
+ "adata-cli",
+ argv=[
+ "adata", "concat", "{input0}", "{input1}",
+ "-o", "{output}", "--merge", "same",
+ ],
+ ),
+ Contender(
+ "anndata (concat_on_disk)",
+ script=_py(
+ "from anndata.experimental import concat_on_disk\n"
+ "concat_on_disk(IN, OUT, join='inner', merge='same')\n"
+ ),
+ ),
+ Contender(
+ "anndata (in memory)",
+ script=_py(
+ "parts = [ad.read_h5ad(p) for p in IN]\n"
+ "ad.concat(parts, join='inner', merge='same')"
+ ".write_h5ad(OUT, compression=COMPRESSION)\n"
+ ),
+ ),
+ ],
+ tags=["regression"],
+ ),
+ # -- subset -----------------------------------------------------------
+ Case(
+ name="subset-query",
+ question="Keep the obs rows matching a predicate on an existing column.",
+ contenders=[
+ Contender(
+ "adata-cli",
+ argv=[
+ "adata", "subset", "{input0}", "-o", "{output}",
+ "-q", "quality < 50",
+ ],
+ ),
+ Contender(
+ "anndata (backed)",
+ script=_py(
+ "obj = ad.read_h5ad(IN[0], backed='r')\n"
+ "keep = obj.obs['quality'] < 50\n"
+ "obj[keep].to_memory().write_h5ad(OUT, compression=COMPRESSION)\n"
+ ),
+ ),
+ Contender(
+ "anndata (in memory)",
+ script=_py(
+ "obj = ad.read_h5ad(IN[0])\n"
+ "obj[obj.obs['quality'] < 50]"
+ ".write_h5ad(OUT, compression=COMPRESSION)\n"
+ ),
+ ),
+ Contender(
+ "scanpy (filter_cells)",
+ env="scanpy",
+ script=_py(
+ "import scanpy as sc\n"
+ "# NOT like-for-like: filter_cells computes n_genes by\n"
+ "# scanning X, which the rows above take as given. Kept\n"
+ "# for scale, labelled so nobody reads it as a race.\n"
+ "obj = ad.read_h5ad(IN[0])\n"
+ "sc.pp.filter_cells(obj, min_genes=1)\n"
+ "obj.write_h5ad(OUT, compression=COMPRESSION)\n"
+ ),
+ ),
+ ],
+ ),
+ # -- conversion -------------------------------------------------------
+ Case(
+ name="h5ad-to-zarr",
+ question="Convert a store to Zarr.",
+ output_suffix=".zarr",
+ contenders=[
+ Contender(
+ "adata-cli",
+ argv=[
+ "adata", "subset", "{input0}", "-o", "{output}",
+ "-q", "quality >= 0",
+ ],
+ ),
+ Contender(
+ "anndata (in memory)",
+ script=_py("ad.read_h5ad(IN[0]).write_zarr(OUT)\n"),
+ ),
+ Contender(
+ "scanpy",
+ unsupported="no streaming converter; scanpy defers to anndata",
+ ),
+ ],
+ ),
+ # -- metadata ---------------------------------------------------------
+ Case(
+ name="inspect",
+ question="Report what is in the store, without reading the matrix.",
+ output_suffix="",
+ contenders=[
+ Contender("adata-cli", argv=["adata", "view", "{input0}"]),
+ Contender(
+ "anndata (read_elem on obs)",
+ script=_py(
+ "from anndata.io import read_elem\n"
+ "with h5py.File(IN[0], 'r') as f:\n"
+ " obs = read_elem(f['obs']); var = read_elem(f['var'])\n"
+ "print(obs.shape, var.shape)\n"
+ ),
+ ),
+ Contender(
+ "anndata (full load)",
+ script=_py("print(ad.read_h5ad(IN[0]))\n"),
+ ),
+ ],
+ tags=["headline"],
+ ),
+ Case(
+ name="export-obs",
+ question="Write the obs table out as CSV.",
+ output_suffix=".csv",
+ contenders=[
+ Contender(
+ "adata-cli",
+ argv=[
+ "adata", "export", "dataframe", "{input0}", "obs",
+ "-o", "{output}",
+ ],
+ ),
+ Contender(
+ "anndata (read_elem on obs)",
+ script=_py(
+ "from anndata.io import read_elem\n"
+ "with h5py.File(IN[0], 'r') as f:\n"
+ " read_elem(f['obs']).to_csv(OUT)\n"
+ ),
+ ),
+ Contender(
+ "anndata (full load)",
+ script=_py("ad.read_h5ad(IN[0]).obs.to_csv(OUT)\n"),
+ ),
+ ],
+ ),
+ # -- split ------------------------------------------------------------
+ Case(
+ name="split-by-sample",
+ question="Write one store per distinct value of an obs column.",
+ output_suffix="",
+ contenders=[
+ Contender(
+ "adata-cli",
+ argv=[
+ "adata", "split", "{input0}", "--by", "sample",
+ "-o", "{outdir}",
+ ],
+ ),
+ Contender(
+ "anndata (hand-written loop)",
+ script=_py(
+ "import os\n"
+ "obj = ad.read_h5ad(IN[0])\n"
+ "os.makedirs(OUT, exist_ok=True)\n"
+ "for key, idx in obj.obs.groupby('sample', observed=True)"
+ ".groups.items():\n"
+ " obj[idx].write_h5ad(\n"
+ " os.path.join(OUT, f'{key}.h5ad'), compression=COMPRESSION\n"
+ " )\n"
+ ),
+ ),
+ ],
+ ),
+ # -- structure and metadata -------------------------------------------
+ Case(
+ name="ls",
+ question="List everything in the store.",
+ output_suffix="",
+ contenders=[
+ Contender("adata-cli", argv=["adata", "ls", "{input0}", "--plain"]),
+ Contender(
+ "h5py (visit)",
+ script=_py(
+ "names = []\n"
+ "with h5py.File(IN[0], 'r') as f:\n"
+ " f.visit(names.append)\n"
+ "print(len(names))\n"
+ ),
+ ),
+ # The obvious tool someone already has. If the CLI cannot beat a
+ # 20-year-old C program at walking an HDF5 file, that is worth
+ # knowing and worth printing.
+ Contender("h5ls -r", argv=["h5ls", "-r", "{input0}"]),
+ ],
+ tags=["headline"],
+ ),
+ Case(
+ name="create",
+ question="Write an empty store with a given obs/var shape.",
+ contenders=[
+ Contender(
+ "adata-cli",
+ argv=[
+ "adata", "create", "{output}",
+ "--n-obs", "{n_obs}", "--n-var", "{n_var}",
+ ],
+ ),
+ Contender(
+ "anndata",
+ script=_py(
+ "import os, pandas as pd\n"
+ "n_obs = int(os.environ['BENCH_N_OBS'])\n"
+ "n_var = int(os.environ['BENCH_N_VAR'])\n"
+ "obs = pd.DataFrame(index=[f'cell_{i}' for i in range(n_obs)])\n"
+ "var = pd.DataFrame(index=[f'gene_{i}' for i in range(n_var)])\n"
+ "ad.AnnData(\n"
+ " X=np.zeros((n_obs, n_var), dtype='float32'), obs=obs, var=var\n"
+ ").write_h5ad(OUT, compression=COMPRESSION)\n"
+ ),
+ ),
+ ],
+ ),
+ # -- export -------------------------------------------------------------
+ Case(
+ name="export-array",
+ question="Write a dense element out as .npy.",
+ output_suffix=".npy",
+ contenders=[
+ Contender(
+ "adata-cli",
+ argv=[
+ "adata", "export", "array", "{input0}", "obsm/X_pca",
+ "-o", "{output}",
+ ],
+ ),
+ Contender(
+ "anndata (read_elem)",
+ script=_py(
+ "from anndata.io import read_elem\n"
+ "with h5py.File(IN[0], 'r') as f:\n"
+ " np.save(OUT, read_elem(f['obsm/X_pca']))\n"
+ ),
+ ),
+ Contender(
+ "anndata (full load)",
+ script=_py("np.save(OUT, ad.read_h5ad(IN[0]).obsm['X_pca'])\n"),
+ ),
+ ],
+ ),
+ Case(
+ name="export-sparse",
+ question="Write X out as Matrix Market text.",
+ output_suffix=".mtx",
+ contenders=[
+ Contender(
+ "adata-cli",
+ argv=[
+ "adata", "export", "sparse", "{input0}", "X", "-o", "{output}",
+ ],
+ ),
+ Contender(
+ "anndata (sparse_dataset)",
+ script=_py(
+ "import scipy.io as sio\n"
+ "from anndata.abc import CSRDataset\n"
+ "from anndata.io import sparse_dataset\n"
+ "with h5py.File(IN[0], 'r') as f:\n"
+ " sio.mmwrite(OUT, sparse_dataset(f['X'])[...])\n"
+ ),
+ ),
+ Contender(
+ "anndata (full load)",
+ script=_py(
+ "import scipy.io as sio\n"
+ "sio.mmwrite(OUT, ad.read_h5ad(IN[0]).X)\n"
+ ),
+ ),
+ ],
+ ),
+ # -- import -------------------------------------------------------------
+ Case(
+ name="import-dataframe",
+ question="Replace obs from a CSV.",
+ sidecar="csv",
+ contenders=[
+ Contender(
+ "adata-cli",
+ argv=[
+ "adata", "import", "dataframe", "{input0}", "obs",
+ "{sidecar_csv}", "-o", "{output}",
+ ],
+ ),
+ Contender(
+ "anndata",
+ script=_py(
+ "import pandas as pd, os\n"
+ "csv = os.environ['BENCH_SIDECAR_CSV']\n"
+ "obj = ad.read_h5ad(IN[0])\n"
+ "obj.obs = pd.read_csv(csv, index_col=0)\n"
+ "obj.write_h5ad(OUT, compression=COMPRESSION)\n"
+ ),
+ ),
+ ],
+ ),
+ # -- concat, outer ------------------------------------------------------
+ Case(
+ name="concat-outer",
+ question="Concatenate two stores keeping the union of variables.",
+ inputs=2,
+ contenders=[
+ Contender(
+ "adata-cli",
+ argv=[
+ "adata", "concat", "{input0}", "{input1}",
+ "-o", "{output}", "--join", "outer",
+ ],
+ ),
+ Contender(
+ "anndata (concat_on_disk)",
+ script=_py(
+ "from anndata.experimental import concat_on_disk\n"
+ "concat_on_disk(IN, OUT, join='outer')\n"
+ ),
+ ),
+ Contender(
+ "anndata (in memory)",
+ script=_py(
+ "parts = [ad.read_h5ad(p) for p in IN]\n"
+ "ad.concat(parts, join='outer')"
+ ".write_h5ad(OUT, compression=COMPRESSION)\n"
+ ),
+ ),
+ ],
+ ),
+ # -- convert ------------------------------------------------------------
+ Case(
+ name="convert-dtype",
+ question="Rewrite X as float32.",
+ contenders=[
+ Contender(
+ "adata-cli",
+ argv=[
+ "adata", "convert", "{input0}", "X", "-o", "{output}",
+ "--dtype", "float32", "--force",
+ ],
+ ),
+ Contender(
+ "anndata (in memory)",
+ script=_py(
+ "obj = ad.read_h5ad(IN[0])\n"
+ "obj.X = obj.X.astype('float32')\n"
+ "obj.write_h5ad(OUT, compression=COMPRESSION)\n"
+ ),
+ ),
+ Contender(
+ "scanpy",
+ unsupported="no dtype rewrite; scanpy defers to anndata",
+ ),
+ ],
+ ),
+ Case(
+ name="convert-layout",
+ question="Transpose X from CSR to CSC.",
+ contenders=[
+ Contender(
+ "adata-cli (streaming)",
+ argv=[
+ "adata", "convert", "{input0}", "X", "-o", "{output}",
+ "--layout", "csc",
+ ],
+ ),
+ Contender(
+ "adata-cli (--in-memory)",
+ argv=[
+ "adata", "convert", "{input0}", "X", "-o", "{output}",
+ "--layout", "csc", "--in-memory",
+ ],
+ ),
+ Contender(
+ "anndata (in memory)",
+ script=_py(
+ "obj = ad.read_h5ad(IN[0])\n"
+ "obj.X = obj.X.tocsc()\n"
+ "obj.write_h5ad(OUT, compression=COMPRESSION)\n"
+ ),
+ ),
+ ],
+ tags=["headline"],
+ ),
+ # -- the claim itself -------------------------------------------------
+ Case(
+ name="rss-vs-size",
+ question=(
+ "Does peak memory track input size, at a fixed --chunk? "
+ "This is the README's claim, stated as a measurement."
+ ),
+ inputs=2,
+ contenders=[
+ Contender(
+ "adata-cli",
+ argv=[
+ "adata", "concat", "{input0}", "{input1}",
+ "-o", "{output}", "--chunk", "1024",
+ ],
+ ),
+ Contender(
+ "anndata (in memory)",
+ script=_py(
+ "parts = [ad.read_h5ad(p) for p in IN]\n"
+ "ad.concat(parts).write_h5ad(OUT, compression=COMPRESSION)\n"
+ ),
+ ),
+ ],
+ tags=["headline", "sweep"],
+ ),
+]
+
+
+def by_name() -> Dict[str, Case]:
+ return {case.name: case for case in CASES}
diff --git a/benchmarks/datasets.py b/benchmarks/datasets.py
new file mode 100644
index 0000000..f3d9281
--- /dev/null
+++ b/benchmarks/datasets.py
@@ -0,0 +1,137 @@
+"""Synthetic inputs for the benchmark, generated deterministically.
+
+Two choices here are not arbitrary.
+
+**Poisson counts, not uniform floats.** Random float32 is incompressible, so
+a uniform-random fixture is three times the size on disk for the same shape
+and the `ci` tier stops fitting in a runner's 14 GB. Counts are also what the
+tool actually sees.
+
+**lzf, not gzip.** gzip on 10^8 values is single-threaded and costs minutes
+per fixture on four vCPUs. The compression setting is pinned on *both* sides
+of every comparison -- see `cases.py` -- because adata-cli forwards the
+source's compression to its output while `write_h5ad` defaults to none, which
+would otherwise make adata-cli's output look smaller for reasons that have
+nothing to do with the tool.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Dict, List
+
+import numpy as np
+
+#: Nonzeros per row block while generating, to keep the generator itself
+#: from needing the memory the benchmark is about to measure.
+_GEN_ROWS = 4096
+
+
+@dataclass(frozen=True)
+class Tier:
+ """One size of input, and what it is for."""
+
+ name: str
+ n_obs: int
+ n_var: int
+ density: float
+ note: str
+
+
+TIERS: Dict[str, Tier] = {
+ "smoke": Tier("smoke", 1_000, 2_000, 0.05, "fast enough to debug the harness"),
+ "ci": Tier("ci", 50_000, 20_000, 0.05, "fits a 16 GB runner with the baseline"),
+ "large": Tier(
+ "large",
+ 500_000,
+ 20_000,
+ 0.05,
+ "in-memory baseline is expected to hit the address-space ceiling",
+ ),
+}
+
+#: The REQ-71798 shape: few rows, many variables, var columns worth merging.
+#: Small enough to run in every tier, and the case the hang was reported on.
+VAR_HEAVY = Tier("var-heavy", 2_000, 36_601, 0.05, "the shape that hung in 0.5.1")
+
+
+def _write(path: Path, tier: Tier, seed: int, *, n_var_columns: int = 2) -> Path:
+ """Write one CSR h5ad of `tier`'s shape, built a row block at a time."""
+ import anndata as ad
+ import h5py
+ import pandas as pd
+ import scipy.sparse as sp
+
+ rng = np.random.default_rng(seed)
+ blocks = []
+ for start in range(0, tier.n_obs, _GEN_ROWS):
+ rows = min(_GEN_ROWS, tier.n_obs - start)
+ block = sp.random(
+ rows,
+ tier.n_var,
+ density=tier.density,
+ format="csr",
+ dtype="float32",
+ random_state=rng,
+ data_rvs=lambda k: rng.poisson(3.0, k).astype("float32") + 1,
+ )
+ blocks.append(block)
+ matrix = sp.vstack(blocks, format="csr")
+
+ obs = pd.DataFrame(
+ {
+ "sample": pd.Categorical(
+ [f"s{i % 8}" for i in range(tier.n_obs)]
+ ),
+ "n_counts": np.asarray(matrix.sum(axis=1)).ravel(),
+ "n_genes": matrix.getnnz(axis=1).astype("int32"),
+ # A predicate on n_genes would select a different fraction at
+ # every tier, since genes-per-cell tracks n_var. This one keeps
+ # exactly half the rows whatever the shape, so the subset case
+ # measures the same work across tiers.
+ "quality": (np.arange(tier.n_obs) % 100).astype("int32"),
+ "barcode": [f"bc{seed}-{i}" for i in range(tier.n_obs)],
+ },
+ index=[f"c{seed}-{i}" for i in range(tier.n_obs)],
+ )
+ var = pd.DataFrame(
+ {
+ "gene_symbol": [f"SYM{i}" for i in range(tier.n_var)],
+ "feature_type": ["Gene Expression"] * tier.n_var,
+ **{
+ f"extra{c}": [f"e{c}-{i}" for i in range(tier.n_var)]
+ for c in range(max(0, n_var_columns - 2))
+ },
+ },
+ index=[f"ENSG{i:011d}" for i in range(tier.n_var)],
+ )
+
+ obj = ad.AnnData(X=matrix, obs=obs, var=var)
+ # A dense element, so the `export array` / `import array` cases have
+ # something of realistic width to move. 50 columns is a typical PCA.
+ obj.obsm["X_pca"] = rng.standard_normal(
+ (tier.n_obs, 50), dtype="float32"
+ )
+ obj.write_h5ad(path, compression="lzf")
+ del obj, matrix, blocks
+
+ with h5py.File(path, "r") as handle: # cheap sanity check
+ assert handle["X"].attrs["encoding-type"] == "csr_matrix"
+ return path
+
+
+def build(directory: Path, tier: Tier, *, count: int = 2) -> List[Path]:
+ """Build (or reuse) `count` inputs of this tier's shape.
+
+ Reused if already present: generation is the slowest part of the job and
+ the content is fully determined by (tier, seed).
+ """
+ directory.mkdir(parents=True, exist_ok=True)
+ paths = []
+ for seed in range(count):
+ path = directory / f"{tier.name}-{seed}.h5ad"
+ if not path.exists():
+ _write(path, tier, seed)
+ paths.append(path)
+ return paths
diff --git a/benchmarks/page_template.md b/benchmarks/page_template.md
new file mode 100644
index 0000000..4964e0e
--- /dev/null
+++ b/benchmarks/page_template.md
@@ -0,0 +1,59 @@
+# Benchmarks
+
+adata-cli measured against anndata, and against scanpy wherever scanpy has a real
+equivalent. The [`Benchmark`](../.github/workflows/benchmark.yml) workflow rewrites
+this page on every tag and keeps each run's raw numbers in `docs/benchmarks/`.
+
+**Peak RSS is the headline, not wall time.** This tool exists so that memory is set
+by `--chunk` rather than by the size of the input. For data that fits in RAM, loading
+the whole thing is often faster — so the tables below carry the rows where adata-cli
+is the slower of the two, because those are the same measurement. The trade is memory
+for time, and a table that hid the cost would not be worth publishing.
+
+## What is measured
+
+Fifteen cases on a GitHub-hosted runner: concat (inner, outer, `--merge same`),
+subset by query, h5ad→zarr, `view`, `ls`, `create`, `export` dataframe / array /
+sparse, `import dataframe`, `split`, and a peak-RSS-against-input-size sweep. Inputs
+are 50,000 × 20,000 at 5% density, plus the 2,000 × 36,601 shape that hung in 0.5.1.
+
+Every run also reports a startup floor (`--version` for each contender), because the
+CLI costs 0.3–1 s to import and `import scanpy` costs 3–8 s; on small inputs that is
+the entire measurement.
+
+`export image`, `export dict`, `import image` and `import dict` are not here. No
+library offers an equivalent, so the rows would only ever read `n/a`. They are covered
+by the complexity guards instead.
+
+
+
+## How peak memory is measured
+
+Each measured command is forked from a small shim process, not from the benchmark
+runner itself. On Linux a forked child inherits its parent's resident pages and
+`execve` folds that into the `maxrss` the kernel reports, so a child of a fat parent
+cannot appear small: with the runner holding 330 MB, a process allocating nothing
+measured 326 MB. The shim brings that floor down to about 8 MB, uniform across every
+contender and visible in the `startup` row.
+
+## A caveat on the streaming claim
+
+Peak memory is not flat in input size. Over a 256× span at a fixed chunk, peak
+allocation grows 1.6× for `export array`, 2.2× for `export dataframe`, 6.5× for
+`export sparse` and 46× for `subset` — far below the input curve, but not constant.
+obs columns are read whole and a dense block is `--chunk` × n_var. The benchmark
+exists to keep that curve visible rather than to assert a guarantee the code does
+not yet meet.
+
+## Running it yourself
+
+```bash
+uv run python -m benchmarks.run --tier smoke --out results.json
+uv run python -m benchmarks.report results.json
+```
+
+Tiers are `smoke` (seconds), `ci` (50,000 × 20,000) and `large` (dispatch only, where
+the in-memory baseline is expected to hit the address-space ceiling).
+
+See [TESTING.md](TESTING.md#performance) for how the baselines are kept honest, and
+how this differs from the complexity guards that gate every pull request.
diff --git a/benchmarks/report.py b/benchmarks/report.py
new file mode 100644
index 0000000..9596dae
--- /dev/null
+++ b/benchmarks/report.py
@@ -0,0 +1,283 @@
+"""Turn results.json into the markdown that gets published.
+
+Two sinks, and the docs page is the authoritative one: artifacts expire, and
+a single absolute number with nothing to compare it to is close to
+uninterpretable. `docs/benchmarks/[.json` keeps the series, and
+`docs/BENCHMARKS.md` renders the latest run plus a history table. Both are in
+git and both are already served by GitHub Pages, so no extra machinery.
+
+ uv run python -m benchmarks.report results.json
+ uv run python -m benchmarks.report results.json --docs docs --publish
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import shutil
+from pathlib import Path
+from typing import Dict, List, Optional
+
+from benchmarks.cases import by_name
+
+
+def _bytes(n: int) -> str:
+ if not n:
+ return "-"
+ for unit, scale in (("GB", 1e9), ("MB", 1e6), ("kB", 1e3)):
+ if n >= scale:
+ return f"{n / scale:.1f} {unit}"
+ return f"{n} B"
+
+
+def _cell(record: Dict) -> tuple:
+ """(wall, rss, output) as they should read in the table."""
+ status = record.get("status")
+ if status == "n/a":
+ return "n/a", "n/a", "n/a"
+ if status == "timeout":
+ # This is how the 0.5.1 hang presented: still running, output stuck at
+ # the size it reached in the first second. Say so, rather than
+ # reporting a large time as though the run had completed.
+ return (
+ f"**timeout** (>{record['wall_s']:.0f} s)",
+ _bytes(record["maxrss_bytes"]),
+ f"{_bytes(record['output_bytes'])} and not growing",
+ )
+ if status == "oom":
+ return "**out of memory**", "hit the ceiling", "-"
+ if status == "failed":
+ return "**failed**", _bytes(record["maxrss_bytes"]), "-"
+
+ output = _bytes(record["output_bytes"])
+ if record.get("output_files", 0) > 1:
+ # Zarr: a directory of small chunks looks far larger under `du` than
+ # the bytes it holds, so the file count goes alongside.
+ output += f" in {record['output_files']} files"
+ return f"{record['wall_s']:.2f} s", _bytes(record["maxrss_bytes"]), output
+
+
+def render(payload: Dict, previous: Optional[Dict] = None) -> str:
+ cases = by_name()
+ before = (
+ {(r["case"], r["contender"]): r for r in previous["results"]}
+ if previous
+ else {}
+ )
+
+ lines: List[str] = []
+ # H2, because this is embedded under the page's own H1 by `build_page`.
+ # The framing lives in `page_template.md`; repeating it here would print
+ # it twice on the published page.
+ lines.append(f"## Results — `{payload['ref']}`")
+ lines.append("")
+
+ n_obs, n_var, density = payload["tier_shape"]
+ vh_obs, vh_var = payload["var_heavy_shape"]
+ cache = (
+ "dropped between runs"
+ if payload.get("page_cache_dropped")
+ else "**warm** (could not be dropped; reads are served from RAM, "
+ "which understates the streaming advantage)"
+ )
+ lines.append("| | |")
+ lines.append("|---|---|")
+ lines.append(f"| Tier | `{payload['tier']}`, {n_obs:,} obs x {n_var:,} var, "
+ f"{density:.0%} dense CSR |")
+ lines.append(f"| Var-heavy shape | {vh_obs:,} obs x {vh_var:,} var |")
+ lines.append(f"| Page cache | {cache} |")
+ lines.append(f"| Address-space ceiling | "
+ f"{_bytes(payload['memory_limit_bytes'])} per process |")
+ lines.append(f"| Repeats | {payload['repeats']} (fastest shown) |")
+ lines.append(f"| Platform | {payload['platform']}, Python "
+ f"{payload['python']} |")
+ versions = ", ".join(f"{k} {v}" for k, v in payload["versions"].items())
+ lines.append(f"| Versions | {versions} |")
+ lines.append(f"| Generated | {payload['generated']} |")
+ lines.append("")
+
+ grouped: Dict[str, List[Dict]] = {}
+ for record in payload["results"]:
+ grouped.setdefault(record["case"], []).append(record)
+
+ for name, records in grouped.items():
+ case = cases.get(name)
+ lines.append(f"### `{name}`")
+ lines.append("")
+ if case:
+ lines.append(f"{case.question}")
+ lines.append("")
+ lines.append("| Contender | Wall time | Peak RSS | Output | vs previous |")
+ lines.append("|---|---|---|---|---|")
+ for record in records:
+ wall, rss, output = _cell(record)
+ lines.append(
+ f"| {record['contender']} | {wall} | {rss} | {output} | "
+ f"{_delta(record, before.get((name, record['contender'])))} |"
+ )
+ note = record.get("note") or (
+ record.get("stderr_tail") if record.get("status") == "n/a" else None
+ )
+ if note:
+ lines.append(f"| | *{note}* | | | |")
+ lines.append("")
+
+ lines.append("---")
+ lines.append("")
+ lines.append(
+ "Rows marked `n/a` are operations the baseline does not offer; that "
+ "is a result, not a gap in the measurement. Where a baseline is "
+ "faster, the row stands as it is -- the trade this tool makes is "
+ "memory for time, and hiding the cost would make the table useless."
+ )
+ lines.append("")
+ lines.append(
+ "**Output size** is the file as the filesystem reports it, which for "
+ "HDF5 includes allocation slack. On a small tier that slack can be "
+ "several times the stored bytes and the column says more about the "
+ "writer's allocation strategy than about the data; at `ci` and above "
+ "it is noise. Zarr stores report a file count alongside, because a "
+ "directory of small chunks measures much larger than it holds."
+ )
+ return "\n".join(lines) + "\n"
+
+
+def _delta(record: Dict, previous: Optional[Dict]) -> str:
+ """Wall time and peak RSS against the last published run."""
+ if not previous or record.get("status") != "ok" or previous.get("status") != "ok":
+ return "-"
+ parts = []
+ for key, label in (("wall_s", "time"), ("maxrss_bytes", "RSS")):
+ old, new = previous.get(key), record.get(key)
+ if not old:
+ continue
+ change = (new - old) / old
+ if abs(change) < 0.10: # within run-to-run noise on a shared runner
+ continue
+ parts.append(f"{label} {change:+.0%}")
+ return ", ".join(parts) if parts else "no change"
+
+
+def history_table(docs: Path) -> str:
+ """One row per published run, newest first."""
+ runs = []
+ for path in sorted((docs / "benchmarks").glob("*.json")):
+ try:
+ payload = json.loads(path.read_text())
+ except Exception: # pragma: no cover
+ continue
+ rows = {(r["case"], r["contender"]): r for r in payload["results"]}
+ key = ("concat-inner", "adata-cli")
+ headline = rows.get(key, {})
+ runs.append(
+ (
+ payload.get("generated", ""),
+ payload.get("ref", path.stem),
+ payload.get("tier", "?"),
+ headline.get("wall_s"),
+ headline.get("maxrss_bytes"),
+ path.name,
+ )
+ )
+ if not runs:
+ return ""
+
+ lines = [
+ "### History",
+ "",
+ "`concat-inner` on adata-cli, run by run. Full results for each are "
+ "in [`docs/benchmarks/`](benchmarks/).",
+ "",
+ "| Run | Ref | Tier | Wall time | Peak RSS | Raw |",
+ "|---|---|---|---|---|---|",
+ ]
+ for generated, ref, tier, wall, rss, filename in sorted(runs, reverse=True):
+ lines.append(
+ f"| {generated} | `{ref}` | {tier} | "
+ f"{f'{wall:.2f} s' if wall else '-'} | "
+ f"{_bytes(rss) if rss else '-'} | [json](benchmarks/{filename}) |"
+ )
+ return "\n".join(lines) + "\n"
+
+
+#: The standing prose of the docs page, with `` marking where
+#: the tables go. Kept as a file rather than inline so the words that explain
+#: the numbers live in one place and survive every republish -- an earlier
+#: version overwrote the whole page with bare tables, which would have thrown
+#: away the framing on the first tag.
+PAGE_TEMPLATE = Path(__file__).with_name("page_template.md")
+
+#: What the template shows before any run has happened.
+NOT_YET_RUN = (
+ "## Results\n\nNo run has been published yet. The next tag fills this in; "
+ "until then, produce one locally with the commands below.\n"
+)
+
+
+def build_page(body: str) -> str:
+ """Wrap rendered results in the page's standing explanation."""
+ template = PAGE_TEMPLATE.read_text()
+ if "" not in template: # pragma: no cover - template edited
+ return template.rstrip() + "\n\n" + body
+ return template.replace("", body.strip())
+
+
+def publish(payload: Dict, results: Path, docs: Path) -> Path:
+ """Copy the raw results in and rewrite the docs page.
+
+ Only the results section is replaced; everything explaining what the
+ numbers mean comes from `page_template.md` and is reinstated every time.
+ """
+ store = docs / "benchmarks"
+ store.mkdir(parents=True, exist_ok=True)
+ ref = payload["ref"].replace("/", "-")
+ shutil.copyfile(results, store / f"{ref}.json")
+
+ previous = _previous(store, skip=f"{ref}.json")
+ page = docs / "BENCHMARKS.md"
+ page.write_text(
+ build_page(render(payload, previous) + "\n" + history_table(docs))
+ )
+ return page
+
+
+def _previous(store: Path, *, skip: str) -> Optional[Dict]:
+ candidates = sorted(
+ (p for p in store.glob("*.json") if p.name != skip),
+ key=lambda p: p.stat().st_mtime,
+ )
+ if not candidates:
+ return None
+ try:
+ return json.loads(candidates[-1].read_text())
+ except Exception: # pragma: no cover
+ return None
+
+
+def main() -> int: # pragma: no cover - CLI entry
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("results", type=Path)
+ parser.add_argument("--docs", type=Path, default=Path("docs"))
+ parser.add_argument(
+ "--publish",
+ action="store_true",
+ help="write docs/benchmarks/][.json and rewrite docs/BENCHMARKS.md",
+ )
+ parser.add_argument("--out", type=Path, help="also write the markdown here")
+ args = parser.parse_args()
+
+ payload = json.loads(args.results.read_text())
+ if args.publish:
+ page = publish(payload, args.results, args.docs)
+ print(f"wrote {page}")
+ text = page.read_text()
+ else:
+ text = render(payload, _previous(args.docs / "benchmarks", skip=""))
+ print(text)
+ if args.out:
+ args.out.write_text(text)
+ return 0
+
+
+if __name__ == "__main__": # pragma: no cover
+ raise SystemExit(main())
diff --git a/benchmarks/run.py b/benchmarks/run.py
new file mode 100644
index 0000000..290667b
--- /dev/null
+++ b/benchmarks/run.py
@@ -0,0 +1,342 @@
+"""Run the benchmark and write results.json.
+
+Not a pytest module. Each contender has to run in its own process for peak
+RSS to mean anything, and the whole thing takes tens of minutes at the `ci`
+tier -- neither of which belongs in a suite that gates merges. The guards
+that *do* gate merges are in `tests/test_performance.py`, and they measure
+operation counts rather than time.
+
+ uv run python -m benchmarks.run --tier smoke
+ uv run python -m benchmarks.run --tier ci --out results.json
+
+Baselines run from environments built once, up front, rather than through
+`uv run --with`. `reference_stores.py` uses the latter to build fixtures,
+where cost is irrelevant; here the first invocation would resolve and download
+several hundred megabytes of wheels straight into the measured wall time, and
+uv's own memory into the measured peak.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import platform
+import shutil
+import subprocess
+import sys
+import time
+from pathlib import Path
+from typing import Dict, List, Optional
+
+from benchmarks import datasets
+from benchmarks._measure import (
+ DEFAULT_MEMORY_LIMIT,
+ Measurement,
+ drop_page_cache,
+ measure,
+)
+from benchmarks.cases import CASES, Case, Contender
+
+#: Packages each baseline environment needs.
+#: `dask` is there for `concat_on_disk`, which imports it to concatenate a
+#: dense element such as obsm and raises ModuleNotFoundError without it.
+#: Installing it is the fair thing to do -- the rule is to give the baseline
+#: the best idiom it has -- and the dependency is itself worth knowing about.
+ENVIRONMENTS: Dict[str, List[str]] = {
+ "anndata": ["anndata", "scipy", "pandas", "h5py", "zarr", "dask"],
+ "scanpy": ["scanpy", "anndata", "scipy", "pandas", "h5py", "zarr", "dask"],
+}
+
+
+def build_environments(root: Path, wanted: List[str]) -> Dict[str, Path]:
+ """Create one venv per baseline and return its interpreter.
+
+ The package list is recorded beside the venv and rebuilt when it changes.
+ Checking only that the interpreter exists means a reused `--work`
+ directory keeps whatever was installed the first time: adding `dask` to
+ ENVIRONMENTS had no effect on an existing tree, and `concat_on_disk`
+ went on failing with ModuleNotFoundError as though that were a finding
+ about anndata.
+ """
+ interpreters: Dict[str, Path] = {}
+ for name in wanted:
+ venv = root / name
+ python = venv / "bin" / "python"
+ stamp = venv / ".packages"
+ wanted_packages = "\n".join(sorted(ENVIRONMENTS[name]))
+ current = stamp.read_text() if stamp.exists() else None
+
+ if not python.exists() or current != wanted_packages:
+ if current is not None and current != wanted_packages:
+ print(f"[env] {name}: package list changed, rebuilding", flush=True)
+ else:
+ print(f"[env] building {name}", flush=True)
+ # --clear because a rebuild runs over an existing tree; without
+ # it `uv venv` refuses and the run dies before any measurement.
+ done = subprocess.run(
+ ["uv", "venv", "--clear", str(venv)],
+ capture_output=True,
+ text=True,
+ )
+ if done.returncode != 0:
+ raise RuntimeError(
+ f"could not create the {name} environment at {venv}:\n"
+ + (done.stderr or done.stdout)
+ )
+ subprocess.run(
+ ["uv", "pip", "install", "--python", str(python), *ENVIRONMENTS[name]],
+ check=True,
+ )
+ stamp.write_text(wanted_packages)
+ interpreters[name] = python
+ return interpreters
+
+
+def _versions(interpreters: Dict[str, Path]) -> Dict[str, str]:
+ """Record exactly what was compared, so a table is interpretable later."""
+ found: Dict[str, str] = {}
+ try:
+ found["adata-cli"] = subprocess.run(
+ ["adata", "--version"], capture_output=True, text=True, timeout=120
+ ).stdout.strip()
+ except Exception: # pragma: no cover
+ found["adata-cli"] = "unknown"
+ for name, python in interpreters.items():
+ code = (
+ "import importlib.metadata as m;"
+ f"print(m.version({name!r}))"
+ )
+ try:
+ found[name] = subprocess.run(
+ [str(python), "-c", code], capture_output=True, text=True, timeout=120
+ ).stdout.strip()
+ except Exception: # pragma: no cover
+ found[name] = "unknown"
+ return found
+
+
+def _run_contender(
+ case: Case,
+ contender: Contender,
+ inputs: List[Path],
+ workdir: Path,
+ interpreters: Dict[str, Path],
+ *,
+ timeout_s: float,
+ scripts_dir: Path,
+ shape: tuple,
+) -> Measurement:
+ workdir.mkdir(parents=True, exist_ok=True)
+ output = workdir / f"{case.name}{case.output_suffix}"
+ outdir = workdir / f"{case.name}-out"
+
+ # The harness never overwrites, so clear any previous run first.
+ for stale in (output, outdir):
+ if stale.is_dir():
+ shutil.rmtree(stale)
+ elif stale.exists():
+ stale.unlink()
+
+ sidecar = _build_sidecar(case, inputs, workdir)
+ # The tier's shape, so a case that builds rather than reads -- `create`
+ # has no input to take its size from -- scales with the tier instead of
+ # making the smoke run as heavy as the ci one.
+ substitutions = {
+ "output": str(output),
+ "outdir": str(outdir),
+ "sidecar_csv": str(sidecar) if sidecar else "",
+ "n_obs": str(shape[0]),
+ "n_var": str(shape[1]),
+ **{f"input{i}": str(p) for i, p in enumerate(inputs)},
+ }
+ env = {"BENCH_N_OBS": str(shape[0]), "BENCH_N_VAR": str(shape[1])}
+ if sidecar:
+ env["BENCH_SIDECAR_CSV"] = str(sidecar)
+
+ if contender.argv is not None:
+ command = [part.format(**substitutions) for part in contender.argv]
+ target = outdir if "{outdir}" in " ".join(contender.argv) else output
+ else:
+ safe = f"{case.name}-{contender.label}".replace(" ", "_").replace("(", "").replace(")", "")
+ script = scripts_dir / f"{safe}.py"
+ script.write_text(contender.script or "")
+ # A script that makes a directory is handed the directory; everything
+ # else is handed the single output path.
+ target = outdir if "makedirs" in (contender.script or "") else output
+ command = [
+ str(interpreters[contender.env]), str(script), *map(str, inputs), str(target)
+ ]
+
+ # Read-only cases produce nothing to size.
+ watched = None if (case.output_suffix == "" and target == output) else target
+ # A contender whose binary is not installed -- `h5ls` ships with the HDF5
+ # tools and is often absent -- comes back as `n/a` from `measure`.
+ return measure(command, output=watched, timeout_s=timeout_s, env=env)
+
+
+def _build_sidecar(case: Case, inputs: List[Path], workdir: Path) -> Optional[Path]:
+ """Build the auxiliary input a case declares, once per case.
+
+ Derived from the real store rather than invented, so an import writes
+ back something the file could plausibly have held.
+ """
+ if case.sidecar != "csv":
+ return None
+ path = workdir / f"{case.name}-sidecar.csv"
+ if path.exists():
+ return path
+ import h5py
+ from anndata.io import read_elem
+
+ with h5py.File(inputs[0], "r") as handle:
+ read_elem(handle["obs"]).to_csv(path)
+ return path
+
+
+def run(
+ tier_name: str,
+ *,
+ out: Path,
+ work: Path,
+ only: Optional[List[str]] = None,
+ repeats: int = 3,
+ timeout_s: float = 900.0,
+ drop_cache: bool = True,
+) -> Dict:
+ tier = datasets.TIERS[tier_name]
+ work.mkdir(parents=True, exist_ok=True)
+ fixtures = work / "fixtures"
+ scripts = work / "scripts"
+ scripts.mkdir(parents=True, exist_ok=True)
+
+ selected = [c for c in CASES if only is None or c.name in only]
+ needed = sorted(
+ {c.env for case in selected for c in case.contenders if c.script}
+ )
+ interpreters = build_environments(work / "envs", needed)
+
+ print(f"[data] building {tier.name} fixtures ({tier.n_obs} x {tier.n_var})",
+ flush=True)
+ main_inputs = datasets.build(fixtures, tier, count=2)
+ var_heavy_inputs = datasets.build(fixtures, datasets.VAR_HEAVY, count=2)
+
+ cache_dropped = drop_page_cache() if drop_cache else False
+
+ results: List[Dict] = []
+ for case in selected:
+ pool = var_heavy_inputs if case.var_heavy else main_inputs
+ inputs = pool[: case.inputs]
+ print(f"[case] {case.name}", flush=True)
+ for contender in case.contenders:
+ if contender.unsupported:
+ results.append(
+ {
+ "case": case.name,
+ "contender": contender.label,
+ "status": "n/a",
+ "note": contender.unsupported,
+ }
+ )
+ print(f" {contender.label}: n/a ({contender.unsupported})")
+ continue
+
+ runs: List[Measurement] = []
+ for _ in range(repeats):
+ if drop_cache:
+ drop_page_cache()
+ runs.append(
+ _run_contender(
+ case,
+ contender,
+ inputs,
+ work / "out",
+ interpreters,
+ timeout_s=timeout_s,
+ scripts_dir=scripts,
+ shape=(
+ (datasets.VAR_HEAVY.n_obs, datasets.VAR_HEAVY.n_var)
+ if case.var_heavy
+ else (tier.n_obs, tier.n_var)
+ ),
+ )
+ )
+ if runs[-1].status != "ok":
+ break # a failure repeats identically; do not pay for it twice
+
+ best = min(runs, key=lambda m: m.wall_s)
+ record = {
+ "case": case.name,
+ "contender": contender.label,
+ # The fastest run, because a slower one differs only by what
+ # else the machine was doing.
+ **best.as_dict(),
+ "runs": len(runs),
+ "wall_s_all": [m.wall_s for m in runs],
+ }
+ results.append(record)
+ print(
+ f" {contender.label}: {best.status} "
+ f"{best.wall_s:.2f}s {best.maxrss_bytes / 1e6:.0f} MB",
+ flush=True,
+ )
+
+ payload = {
+ "tier": tier.name,
+ "tier_shape": [tier.n_obs, tier.n_var, tier.density],
+ "var_heavy_shape": [datasets.VAR_HEAVY.n_obs, datasets.VAR_HEAVY.n_var],
+ "generated": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
+ "ref": _git_ref(),
+ "platform": f"{platform.system()} {platform.machine()}",
+ "python": sys.version.split()[0],
+ "memory_limit_bytes": DEFAULT_MEMORY_LIMIT,
+ "page_cache_dropped": cache_dropped,
+ "repeats": repeats,
+ "versions": _versions(interpreters),
+ "results": results,
+ }
+ out.parent.mkdir(parents=True, exist_ok=True)
+ out.write_text(json.dumps(payload, indent=2) + "\n")
+ print(f"\nwrote {out}")
+ return payload
+
+
+def _git_ref() -> str:
+ for args in (["git", "describe", "--tags", "--exact-match"],
+ ["git", "rev-parse", "--short", "HEAD"]):
+ try:
+ done = subprocess.run(args, capture_output=True, text=True, timeout=30)
+ if done.returncode == 0 and done.stdout.strip():
+ return done.stdout.strip()
+ except Exception: # pragma: no cover
+ pass
+ return "unknown"
+
+
+def main() -> int: # pragma: no cover - CLI entry
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--tier", default="smoke", choices=sorted(datasets.TIERS))
+ parser.add_argument("--out", type=Path, default=Path("results.json"))
+ parser.add_argument("--work", type=Path, default=Path(".bench"))
+ parser.add_argument("--case", action="append", dest="only")
+ parser.add_argument("--repeats", type=int, default=3)
+ parser.add_argument("--timeout", type=float, default=900.0)
+ parser.add_argument(
+ "--keep-cache",
+ action="store_true",
+ help="do not try to drop the page cache between runs",
+ )
+ args = parser.parse_args()
+ run(
+ args.tier,
+ out=args.out,
+ work=args.work,
+ only=args.only,
+ repeats=args.repeats,
+ timeout_s=args.timeout,
+ drop_cache=not args.keep_cache,
+ )
+ return 0
+
+
+if __name__ == "__main__": # pragma: no cover
+ raise SystemExit(main())
diff --git a/docs/BENCHMARKS.md b/docs/BENCHMARKS.md
new file mode 100644
index 0000000..de52293
--- /dev/null
+++ b/docs/BENCHMARKS.md
@@ -0,0 +1,78 @@
+# Benchmarks
+
+adata-cli measured against anndata, and against scanpy wherever scanpy has a real
+equivalent. The [`Benchmark`](../.github/workflows/benchmark.yml) workflow rewrites
+this page on every tag and keeps each run's raw numbers in `docs/benchmarks/`.
+
+**Peak RSS is the headline, not wall time.** This tool exists so that memory is set
+by `--chunk` rather than by the size of the input. For data that fits in RAM, loading
+the whole thing is often faster — so the tables below carry the rows where adata-cli
+is the slower of the two, because those are the same measurement. The trade is memory
+for time, and a table that hid the cost would not be worth publishing.
+
+## What is measured
+
+Fifteen cases on a GitHub-hosted runner: concat (inner, outer, `--merge same`),
+subset by query, h5ad→zarr, `view`, `ls`, `create`, `export` dataframe / array /
+sparse, `import dataframe`, `split`, and a peak-RSS-against-input-size sweep. Inputs
+are 50,000 × 20,000 at 5% density, plus the 2,000 × 36,601 shape that hung in 0.5.1.
+
+Every run also reports a startup floor (`--version` for each contender), because the
+CLI costs 0.3–1 s to import and `import scanpy` costs 3–8 s; on small inputs that is
+the entire measurement.
+
+`export image`, `export dict`, `import image` and `import dict` are not here. No
+library offers an equivalent, so the rows would only ever read `n/a`. They are covered
+by the complexity guards instead.
+
+## Results
+
+No tagged run has been published yet — the next tag fills this in. These
+numbers are from a `ci` run during development, as an indication of what the
+tables will say.
+
+| Case | adata-cli | best baseline |
+|---|---|---|
+| `concat-inner` | **202 MB**, 2.35 s | 439 MB, 16.44 s (`concat_on_disk`) |
+| `concat-outer` | **202 MB**, 2.60 s | 436 MB, **2.24 s** (`concat_on_disk`) |
+| `create` | **78 MB**, 0.28 s | 2,225 MB, 2.85 s |
+| `inspect` | **63 MB**, 0.31 s | 138 MB, 0.79 s (`read_elem`) |
+| `import-dataframe` | **107 MB**, 0.41 s | 571 MB, 1.84 s |
+| `export-sparse` | **68 MB**, 10.79 s | 759 MB, **1.90 s** (full load) |
+| `ls` | 63 MB, 0.36 s | **7 MB, 0.03 s** (`h5ls -r`) |
+
+The last two rows are why this is not a leaderboard. `export sparse` streams in a
+tenth of the memory and takes five times as long. `h5ls -r` walks the file in a
+hundredth of our time and a ninth of our memory, being a C program rather than a
+Python process that must import typer, rich, h5py and zarr before it starts.
+
+## How peak memory is measured
+
+Each measured command is forked from a small shim process, not from the benchmark
+runner itself. On Linux a forked child inherits its parent's resident pages and
+`execve` folds that into the `maxrss` the kernel reports, so a child of a fat parent
+cannot appear small: with the runner holding 330 MB, a process allocating nothing
+measured 326 MB. The shim brings that floor down to about 8 MB, uniform across every
+contender and visible in the `startup` row.
+
+## A caveat on the streaming claim
+
+Peak memory is not flat in input size. Over a 256× span at a fixed chunk, peak
+allocation grows 1.6× for `export array`, 2.2× for `export dataframe`, 6.5× for
+`export sparse` and 46× for `subset` — far below the input curve, but not constant.
+obs columns are read whole and a dense block is `--chunk` × n_var. The benchmark
+exists to keep that curve visible rather than to assert a guarantee the code does
+not yet meet.
+
+## Running it yourself
+
+```bash
+uv run python -m benchmarks.run --tier smoke --out results.json
+uv run python -m benchmarks.report results.json
+```
+
+Tiers are `smoke` (seconds), `ci` (50,000 × 20,000) and `large` (dispatch only, where
+the in-memory baseline is expected to hit the address-space ceiling).
+
+See [TESTING.md](TESTING.md#performance) for how the baselines are kept honest, and
+how this differs from the complexity guards that gate every pull request.
diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md
index a5d27c6..4cac9b8 100644
--- a/docs/COMMANDS.md
+++ b/docs/COMMANDS.md
@@ -81,6 +81,52 @@ adata subset data.h5ad --inplace --obs barcodes.txt
`raw/` is carried over and matched against its **own** var axis, which usually
holds more genes than the main object.
+## `convert`
+
+Change a matrix's dtype, layout or density. Counts held as float64 cost twice
+the disk and twice the read for no information; a tool that wants CSC cannot
+use a CSR store; and `concat` refuses inputs whose encodings disagree.
+
+```bash
+adata convert data.h5ad X -o out.h5ad --dtype float32
+adata convert data.h5ad X -o out.h5ad --layout csc
+adata convert data.h5ad X --inplace --dtype float32 --indices-dtype int32
+adata convert data.h5ad --all -o out.h5ad --dtype float32
+adata convert data.h5ad layers/counts -o out.h5ad --layout dense --force
+```
+
+| Flag | Meaning |
+|---|---|
+| `--output`, `-o` | Output path. Required unless `--inplace` |
+| `--inplace` | Replace the source (written to a temporary path first) |
+| `--all` | Convert `X`, every layer and `raw/X` |
+| `--dtype` | New dtype for the values, e.g. `float32` |
+| `--indices-dtype` | New dtype for sparse indices: `int32` or `int64` |
+| `--layout` | `csr`, `csc`, `dense` or `sparse` |
+| `--force` | Convert despite a lossy cast or a large size increase |
+| `--in-memory` | Transpose in memory rather than streaming |
+| `--chunk`, `-C` | Row chunk size for dense matrices |
+| `--zarr-format` | Zarr version to write; defaults to the source's |
+
+Two things are refused before anything is written. A cast that would not
+round-trip -- checked by casting every value and casting it back, not by
+comparing dtypes -- and a densification that would inflate the store beyond
+four times its size. `--force` overrides either, and the message says how
+many values would change or how large the result would be.
+
+The index dtype is **preserved** unless `--indices-dtype` asks otherwise, so
+narrowing the values does not silently widen the indices and leave the file
+bigger than it started. `indices` and `indptr` keep their own widths, which
+can differ: a narrow matrix with more than 2^31 nonzeros needs int64 offsets
+over int32 coordinates, and both are range-checked before writing.
+
+The output path may not name the input; use `--inplace`, which writes to a
+temporary file and swaps it in only once the conversion has finished.
+
+Transposing streams by default and works on matrices too large to load, at
+the cost of two extra passes over the nonzeros. `--in-memory` is faster when
+the matrix fits.
+
## `split`
One store per distinct value of a column.
@@ -118,7 +164,7 @@ adata concat a.h5ad b.h5ad -o m.h5ad --keys a,b --index-unique - --uns-merge sam
| `--label` | Add an obs column recording each cell's source |
| `--keys` | Names for the inputs; defaults to their filenames |
| `--index-unique` | Delimiter for suffixing obs names with their key |
-| `--merge` / `--uns-merge` | `same`, `unique`, `first`, `only`; default drops |
+| `--merge` / `--uns-merge` | `drop` (default), `same`, `unique`, `first`, `only` |
| `--fill-value` | Value for dense cells introduced by an outer join |
obs columns keep their dtypes: categoricals union their category sets, nullable
diff --git a/docs/TESTING.md b/docs/TESTING.md
index 7159a14..d363cb1 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -4,6 +4,7 @@
uv sync --extra dev
uv run pytest # everything
uv run pytest -m "not integration" # fast: no environment building, ~40s
+uv run pytest -m perf # just the tracing complexity guards
uv run pytest -m integration # compatibility across anndata releases
```
@@ -22,6 +23,8 @@ and the compatibility suite as a separate job.
| `test_anndata_roundtrip.py` | anndata writes the fixtures, reads back our output |
| `test_anndata_versions.py` | Compatibility with six real anndata releases (see below) |
| `test_commands_phase2.py`, `test_commands_coverage.py`, `test_cli.py` | Command surfaces and error paths |
+| `test_performance.py` | Complexity guards -- what an operation costs, not how long it takes (see below) |
+| `test_benchmark_harness.py` | That the benchmark's measurement is trustworthy |
| `test_subset.py`, `test_export.py`, `test_import.py`, `test_info_read.py`, `test_zarr.py`, `test_query.py` | Per-feature unit tests |
## Writing a test
@@ -44,6 +47,301 @@ For CLI surfaces, use the module-level `CliRunner` and assert on
ANSI before matching message text — Rich also wraps long lines, so collapse
whitespace.
+## Performance
+
+Two mechanisms, and confusing them is the main way this gets misused.
+
+| | `tests/test_performance.py` | `benchmarks/` |
+|---|---|---|
+| Measures | operation **counts** | wall time, peak RSS, output size |
+| Runs | every CI job, both interpreters | tags, and `workflow_dispatch` |
+| Gates | **yes** -- it fails the build | never |
+| Data | 64-4096 elements | 50,000 x 20,000 |
+
+### Why the guards do not use a clock
+
+A test that can fail because a runner was busy does not belong in a merge
+gate. Every number in `test_performance.py` is deterministic: the same input
+gives the same count on every machine. That is what lets it block a merge.
+
+This is not new -- `test_commands_phase2.py` already counts `read_str_all`
+calls for exactly this reason. The performance file generalises the idea; it
+does not replace those tests, and where an exact count is derivable an exact
+count is still better than a ratio, because it says what the number *should*
+be rather than only that it did not grow.
+
+### What is counted
+
+`tests/perf_counters.py` patches four seams, all verified against the pinned
+h5py 3.15.1 and zarr 3.1.5:
+
+| Hook | Sees |
+|---|---|
+| `h5py.Dataset.__getitem__` | HDF5 reads |
+| `h5py.Dataset.__setitem__` | HDF5 writes |
+| `h5py.Group.create_dataset` | HDF5 writes made at creation |
+| `zarr.Array.__getitem__` | Zarr reads, v2 and v3 alike |
+| `zarr.storage.LocalStore.get` | chunk and metadata fetches |
+| `LocalStore.set` / `.delete` | write storms |
+
+Both write seams are needed. `create_dataset(name, data=...)` writes its payload
+during creation and never touches `__setitem__`, so hooking only the latter leaves
+every `import` and `create` guard measuring zero — which is how they were first
+written, and what the lower bound below caught.
+
+Use `io.reads` for a read path, `io.work` (reads plus writes) for `import` and
+`create`.
+
+The libraries are patched rather than anything in `src/adata/`. An in-repo
+seam would only see the call sites that remembered to use it, which is the
+wrong property for a guard meant to catch the read nobody thought of -- and
+the matrix paths slice the backend objects directly anyway.
+
+Elements are counted, not just calls: the `--merge` bug was n calls each
+reading n elements, and counting calls alone would miss a vectorised variant
+that reads n elements n times in one call.
+
+**Known bypasses.** `Dataset.read_direct`, `np.asarray(dataset)` (it goes via
+`__array__`), `dataset.asstr()[...]` and `.fields()` reach the file without
+passing any hook. None are used today. If you add one, the counters will
+quietly report less work than happened -- which is why every guard also
+asserts a lower bound, and why there are two canary tests with known absolute
+counts. If a canary fails, fix the hooks before trusting anything else here.
+
+### The invariant
+
+Three sizes at 4x spacing, comparing successive **increments**:
+
+```python
+d1 = c(4n) - c(n)
+d2 = c(16n) - c(4n)
+assert d2 <= 6 * d1 # grows no faster than linearly
+assert d1 >= 4n # and the counters actually saw something
+```
+
+The increment form is the point. Comparing raw counts needs an additive
+constant to absorb fixed setup cost, and there is no principled value for
+one: too small and it is flaky, too large and a quadratic with a small
+coefficient hides underneath at n=64. Any constant appears in both
+differences and cancels exactly.
+
+Why 6: at 4x spacing the increment ratio is 4.0 for linear work, about 4.4
+for n log n, 8 for n^1.5 and 16 for quadratic. 6 sits in the gap with room on
+both sides. `test_growth_limit_separates_linear_from_super_linear` asserts
+that calibration rather than leaving it as a comment, and it fails if anyone
+changes `SIZES` without recomputing the limit.
+
+**Scale one axis per test, and name it in the test id.** Scaling two at once
+makes legitimate work look quadratic -- an outer-join concat really does
+produce n_obs x n_var_union cells. The axes worth separate coverage are
+`n_var`, `n_obs`, `n_inputs`, `n_obs_columns`, `n_var_columns`,
+`n_categories` and `n_groups`.
+
+### Three invariants, not one
+
+`assert_grows_linearly` only catches super-linear growth. Most commands need exactly
+that, but two claims this tool makes are stronger, and a linear guard would happily
+accept a 64x increase in a command that is supposed to read nothing.
+
+| Helper | Claim | Used for |
+|---|---|---|
+| `assert_grows_linearly` | no worse than linear in one axis | most commands |
+| `assert_grows_slower_than_input` | grows at least N times slower than the input | streaming at a fixed chunk |
+| `assert_independent_of` | does not grow at all | inspection, and grouping work per row |
+
+**Inspection is free, and that is an exact number.** `view` and `ls` reach only
+`.shape`, `.dtype` and `.attrs`: `axis_len` goes through `element_len`, which reads a
+shape, and `_array_details` and `_infer_untagged` never touch a value. So the guard
+asserts **zero** data elements read rather than a ratio — 0 against 0 is not a
+meaningful ratio, and the moment inspection reads one column the answer stops being
+zero however the store scales. A companion test exports the same fixture to prove
+there was data there to read, so the zero cannot pass because the fixture was empty.
+
+**Streaming is bounded well below the input, which is weaker than it sounds and is
+what the measurements support.** Over a 256x span at a fixed chunk:
+
+| | peak allocation growth |
+|---|---|
+| `export array` | 1.6x |
+| `export dataframe` | 2.2x |
+| `export sparse` | 6.5x |
+| `subset` | 46x |
+
+Only `export array` is close to flat, so only it is held to a near-flat bound. None is
+asserted as flat outright. `subset` is the weakest because obs columns are
+materialised one at a time — a known gap that `benchmarks/` reports rather than these
+guards conceal. Streamed `export sparse` is separately asserted to stay under a
+quarter of what `--in-memory` costs, both measured in the same run so the factor holds
+on any machine.
+
+### Where counting is not enough
+
+Two real defects in `concat.py` were invisible to all of the above, and both
+needed their own instrument:
+
+- **Category merging** used `if category not in categories` on a list. The
+ category lists are read once either way, so no read counter sees it, and
+ `x not in lst` is a single bytecode, so the line tracer does not either --
+ the quadratic is inside C-level list membership. Counting string
+ comparisons through a `str` subclass is what made it visible: 2,096,128
+ comparisons at k=1024.
+- **A per-element loop over obs rows.** Linear, just with a fat constant, so
+ no ratio catches it. The guard compares executed Python lines per row
+ against the numeric column path measured in the same run -- self-calibrating,
+ so it needs no hand-tuned budget and holds across interpreters.
+
+A third case needed a third instrument. `split --by` grouped rows with
+`np.nonzero(values == label)` inside a loop over distinct labels, rescanning each
+chunk once per label: O(n_rows x n_groups), which at a million cells and a thousand
+samples is 10^9 comparisons. No read counter moves, because the chunk is already in
+memory; nothing lasting is allocated; and the Python line count per label is constant.
+`count_scanned_elements` counts what is handed to numpy's scanning primitives and
+makes it visible — 16,384 elements at 4 groups against 1,048,576 at 256.
+
+It is a **floor, not a measurement**: an operator such as `values == label` dispatches
+to the ufunc in C and never passes the patched `np.equal`, and `arr.argsort()` is
+invisible for the same reason. That is the right property for a guard — it can only
+under-report — but it means a guard using it must also assert a lower bound, so that
+under-reporting to nothing fails instead of passing.
+
+`count_allocations` (tracemalloc), `count_lines` (`sys.settrace`) and
+`count_scanned_elements` are the tools for all of this. The line tracer costs a 10-50x
+slowdown, so its tests carry the `perf` marker and stay small.
+
+### What the guards deliberately do not claim
+
+Obs columns are read whole, so peak allocation for a concat is O(n_obs), not
+O(`--chunk`). The streaming guarantee holds for X, not for obs annotation.
+The guards only stop that getting worse than linear; `benchmarks/` reports
+the actual curve.
+
+### What is covered
+
+Every subcommand has a guard. When you add a command, add one: pick the axis its cost
+should scale with, scale only that, and hold everything else fixed.
+
+| Command | Axis scaled | Invariant |
+|---|---|---|
+| `view`, `view --types`, `ls`, `ls --long`, `ls --plain` | n_obs, n_var | **zero** data reads |
+| `create` | n_obs | linear in writes, both generated names and a name file |
+| `concat` | n_var, n_obs, n_inputs, n_obs_columns, n_var_columns | linear; every `--merge` strategy and both joins |
+| `concat --label`, `--index-unique` | n_obs | linear |
+| `concat` category union | n_categories | bounded string comparisons |
+| `subset` by name, by query | n_obs, n_var | linear |
+| `split --by`, `--axis var` | n_groups | linear reads, **flat** scan work |
+| `export dataframe` | n_obs, n_obs_columns | linear |
+| `export array`, `sparse` (both paths), `dict`, `image` | elements, nnz, keys, pixels | linear |
+| `import dataframe`, `array`, `sparse`, `dict`, `image` | rows, elements, nnz, keys, pixels | linear in writes |
+| h5ad to zarr | n_obs | linear |
+
+The `slow` marker is on the three guards that build a 65,536-row store; they still gate
+merges, and `-m "not slow"` skips them locally. `perf` is on the tracing guards.
+
+### Reading a failure
+
+It says cost grew super-linearly on the named axis. The assertion message
+carries the whole measured series and the computed ratio. Usually the code is
+wrong. Occasionally the expectation is -- an operation legitimately gained
+work -- and then the new number needs a comment saying why, in the same style
+as the exact counts in `test_commands_phase2.py`.
+
+## The comparative benchmark
+
+```bash
+uv run python -m benchmarks.run --tier smoke --out results.json
+uv run python -m benchmarks.report results.json
+```
+
+Tiers are `smoke` (1,000 x 2,000, seconds), `ci` (50,000 x 20,000) and
+`large` (500,000 x 20,000, dispatch only, where the in-memory baseline is
+expected to hit the ceiling). Every tier also builds the 2,000 x 36,601
+var-heavy shape, which is what hung in 0.5.1.
+
+`ci` is cheaper than it looks: 580 MB of fixtures and three cases took 43
+seconds end to end on a laptop, so the full set with three repeats is minutes
+rather than the hour the workflow allows. The 90-minute timeout is headroom
+for `large`, not an estimate.
+
+A `ci` run measured while writing this, for a sense of what the tables say — and of
+what they are for. Peak RSS first, wall time second:
+
+| Case | adata-cli | best baseline |
+|---|---|---|
+| `concat-inner` | **202 MB**, 2.35 s | 439 MB, 16.44 s (`concat_on_disk`) |
+| `concat-outer` | **202 MB**, 2.60 s | 436 MB, **2.24 s** (`concat_on_disk`) |
+| `inspect` | **63 MB**, 0.31 s | 138 MB, 0.79 s (`read_elem`) |
+| `create` | **78 MB**, 0.28 s | 2,225 MB, 2.85 s |
+| `import-dataframe` | **107 MB**, 0.41 s | 571 MB, 1.84 s |
+| `export-sparse` | **68 MB**, 10.79 s | 759 MB, **1.90 s** (full load) |
+| `ls` | 63 MB, 0.25 s | **7 MB, 0.01 s** (`h5ls -r`) |
+
+The last two rows are the reason the report is not a leaderboard. `export sparse`
+streams in a tenth of the memory and takes five times as long; `h5ls` walks the file
+in a hundredth of our time and a ninth of our memory, because it is C and does not
+start a Python interpreter. Both belong in the table. A benchmark that only published
+the rows we win would not be worth running.
+
+Note also that 202 MB is not flat in input size — obs columns are read whole, and a
+dense block is `--chunk` x n_var. The benchmark exists to keep that curve visible
+rather than to assert a claim the code does not yet meet.
+
+Results are published to `docs/BENCHMARKS.md` and `docs/benchmarks/.json`
+on every tag, and appended to the GitHub release notes if a release exists.
+Artifacts expire; the docs page is the durable series.
+
+### How it measures
+
+- **Every command is forked from a small shim, not from the runner.** On Linux a
+ forked child inherits its parent's resident pages and `execve` folds that into the
+ reported `maxrss`, so a child of a process that has imported anndata cannot measure
+ below roughly 200 MB — which would have flattened every row in the table. Measured
+ with a 330 MB parent: 326 MB for a no-op child directly, 8 MB through the shim.
+ `posix_spawn` does not help (329 MB), so dropping `preexec_fn` is not a fix.
+- **`os.wait4`, not `resource.getrusage`.** `RUSAGE_CHILDREN` is a running
+ maximum over every child ever reaped, so one large case would poison every
+ later row. Output goes to temporary files rather than pipes, because
+ `communicate()` reaps the child and loses its rusage.
+- **`RLIMIT_AS` at 12 GiB on every child.** Cases where the in-memory
+ baseline cannot cope are the whole point, but an uncontained OOM kills the
+ runner agent and the job ends with no report. With a ceiling it is a row
+ that says `out of memory` at a limit we can state. macOS refuses
+ `RLIMIT_AS`, so a local run is unbounded.
+- **Baselines run from venvs built up front**, not `uv run --with`. The
+ latter is right for building fixtures, as `reference_stores.py` does, and
+ wrong here: the first invocation would put several hundred megabytes of
+ wheel downloads into the measured wall time and uv's own memory into the
+ measured peak. scanpy therefore never enters `uv.lock` or the image.
+
+### Rules that keep the comparison honest
+
+These live in `benchmarks/cases.py` as well, because this is the part that
+decays fastest.
+
+- **Use the best idiom the baseline has.** Comparing `export dataframe`
+ against a full `read_h5ad()` is a strawman -- anndata reads just `obs` via
+ `read_elem`. The good idiom is the primary row; the naive load may appear
+ only as a clearly labelled second row.
+- **Pin compression on both sides.** adata-cli forwards the source's
+ settings; `write_h5ad` defaults to none.
+- **`n/a` is a result.** Where scanpy has no equivalent, or
+ `concat_on_disk` refuses, say which and why. An omitted row reads as an
+ oversight.
+- **Include the startup floor.** The CLI costs 0.3-1 s to import, and
+ `import scanpy` 3-8 s; on a small tier that is the entire measurement.
+- **Do not hide the rows where the baseline wins.** `_concat_csr` loops per
+ row in Python and scipy's C `vstack` will often beat it on time at many
+ times the memory. That trade is the argument for the tool.
+- **Give the baseline everything it needs.** `concat_on_disk` imports `dask` to
+ concatenate a dense element and fails outright without it, so the baseline
+ environments install it. Measuring a library crippled by a missing optional
+ dependency would be measuring our own setup.
+- **Four commands are deliberately not benchmarked.** `export image`, `export dict`,
+ `import image` and `import dict` have no library equivalent, so their rows would only
+ ever read `n/a` while adding runtime to every tag. The complexity guards cover them.
+- **Say whether the page cache was dropped.** A fixture written seconds ago
+ is entirely in RAM, which understates streaming. The runner can drop it; a
+ laptop usually cannot, and the report states which happened.
+
## Compatibility testing against real anndata releases
`test_anndata_versions.py` does not trust this repo's idea of the format. For
diff --git a/docs/index.md b/docs/index.md
index ed04b98..b90e217 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -34,8 +34,11 @@ docker run --rm -it -v /path/to/data:/data \
out in `.h5ad`, and what this tool does with it.
- **[Element spec: Zarr](ELEMENTS_zarr.md)** — the same for `.zarr`, including
the v2/v3 differences.
-- **[Testing](TESTING.md)** — how the suite is organised, and how compatibility
- is verified against six real anndata releases.
+- **[Testing](TESTING.md)** — how the suite is organised, how compatibility is
+ verified against six real anndata releases, and the complexity guards that
+ keep cost regressions out.
+- **[Benchmarks](BENCHMARKS.md)** — peak memory and wall time against anndata
+ and scanpy, remeasured and republished on every release.
## At a glance
@@ -48,6 +51,8 @@ adata subset data.h5ad -o cortex.h5ad --obs-query "cluster == Cortex_2"
adata split data.h5ad --by sample -o per_sample/
adata concat per_sample/*.h5ad -o merged.h5ad --join outer --label sample
+adata convert data.h5ad X -o small.h5ad --dtype float32
+
adata create new.h5ad --n-obs 5000 --n-var 2000
adata import sparse new.h5ad X counts.mtx --inplace
```
diff --git a/pyproject.toml b/pyproject.toml
index 7767eca..c4de0fa 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -3,7 +3,7 @@
# so the distribution is published as `pyadata-cli`. The import package
# and the command are both still `adata`.
name = "pyadata-cli"
-version = "0.5.1"
+version = "0.6.0"
description = "Streaming CLI for exploring and editing large AnnData .h5ad and .zarr stores"
readme = "README.md"
requires-python = ">=3.12"
diff --git a/pytest.ini b/pytest.ini
index e7b2969..a116cbf 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -6,4 +6,5 @@ python_functions = test_*
addopts = -v --strict-markers --tb=short --timeout=300 --timeout-method=thread
markers =
slow: marks tests as slow (deselect with '-m "not slow"')
+ perf: complexity guards that trace Python execution (10-50x slower)
integration: builds environments with uv and needs the network on first run
diff --git a/src/adata/cli.py b/src/adata/cli.py
index ed7dc8d..cd496a5 100644
--- a/src/adata/cli.py
+++ b/src/adata/cli.py
@@ -6,9 +6,11 @@
from rich.console import Console
import typer
+from adata.core.convert import LAYOUTS
from adata.commands import (
- MERGE_STRATEGIES,
+ MERGE_CHOICES,
concat_stores,
+ convert_store,
create_store,
split_store,
list_store,
@@ -413,12 +415,14 @@ def concat(
merge: Optional[str] = typer.Option(
None,
"--merge",
- help="How to reconcile var columns: same, unique, first, only (default: drop)",
+ help="How to reconcile var columns: drop, same, unique, first, only "
+ "(default: drop)",
),
uns_merge: Optional[str] = typer.Option(
None,
"--uns-merge",
- help="How to reconcile uns: same, unique, first, only (default: drop)",
+ help="How to reconcile uns: drop, same, unique, first, only "
+ "(default: drop)",
),
fill_value: float = typer.Option(
0.0, "--fill-value", help="Value for dense cells introduced by an outer join"
@@ -442,13 +446,18 @@ def concat(
adata concat a.h5ad b.h5ad -o m.h5ad --keys a,b --index-unique - --uns-merge same
"""
for name, value in (("--merge", merge), ("--uns-merge", uns_merge)):
- if value is not None and value not in MERGE_STRATEGIES:
+ if value is not None and value not in MERGE_CHOICES:
console.print(
f"[bold red]Error:[/] {name} must be one of: "
- f"{', '.join(MERGE_STRATEGIES)}"
+ f"{', '.join(MERGE_CHOICES)}"
)
raise typer.Exit(code=1)
+ # "drop" is the default, and naming it explicitly has to be allowed: a
+ # config that spells out the default should not be rejected.
+ merge = None if merge == "drop" else merge
+ uns_merge = None if uns_merge == "drop" else uns_merge
+
try:
concat_stores(
files,
@@ -469,6 +478,134 @@ def concat(
raise typer.Exit(code=1)
+# ============================================================================
+# CONVERT command
+# ============================================================================
+@app.command("convert")
+def convert(
+ file: Path = typer.Argument(
+ ...,
+ help="Input .h5ad/.zarr",
+ exists=True,
+ readable=True,
+ dir_okay=True,
+ file_okay=True,
+ ),
+ entries: Optional[List[str]] = typer.Argument(
+ None,
+ help="Matrix paths to convert, e.g. 'X', 'layers/counts', 'raw/X'",
+ ),
+ output: Optional[Path] = typer.Option(
+ None,
+ "--output",
+ "-o",
+ help="Output .h5ad/.zarr path. Required unless --inplace.",
+ dir_okay=True,
+ file_okay=True,
+ ),
+ inplace: bool = typer.Option(
+ False,
+ "--inplace",
+ help="Modify source file directly.",
+ ),
+ convert_all: bool = typer.Option(
+ False,
+ "--all",
+ help="Convert X, every layer, and raw/X",
+ ),
+ dtype: Optional[str] = typer.Option(
+ None,
+ "--dtype",
+ help="New dtype for the values, e.g. float32",
+ ),
+ indices_dtype: Optional[str] = typer.Option(
+ None,
+ "--indices-dtype",
+ help="New dtype for sparse indices: int32 or int64",
+ ),
+ layout: Optional[str] = typer.Option(
+ None,
+ "--layout",
+ help="Target layout: csr, csc, dense or sparse",
+ ),
+ force: bool = typer.Option(
+ False,
+ "--force",
+ help="Convert despite a lossy cast or a large size increase",
+ ),
+ in_memory: bool = typer.Option(
+ False,
+ "--in-memory",
+ help="Transpose in memory instead of streaming (faster if it fits)",
+ ),
+ chunk_rows: int = typer.Option(
+ 1024,
+ "--chunk",
+ "-C",
+ help="Row chunk size for dense matrices",
+ ),
+ zarr_format: Optional[int] = typer.Option(
+ None,
+ "--zarr-format",
+ help="Zarr spec version to write (defaults to the source store's)",
+ ),
+) -> None:
+ """
+ Change a matrix's dtype, layout or density.
+
+ Counts held as float64 cost twice the disk and twice the read for no
+ information; a tool that wants CSC cannot use a CSR store; and concat
+ refuses inputs whose encodings disagree. All three are this command.
+
+ A cast that would not round-trip is refused before anything is written,
+ as is a densification that would inflate the store; --force overrides
+ both. Transposing streams by default, so it works on matrices too large
+ to load.
+
+ Examples:
+ adata convert data.h5ad X -o out.h5ad --dtype float32
+ adata convert data.h5ad X -o out.h5ad --layout csc
+ adata convert data.h5ad X --inplace --dtype float32 --indices-dtype int32
+ adata convert data.h5ad --all -o out.h5ad --dtype float32
+ """
+ if not inplace and output is None:
+ console.print(
+ "[bold red]Error:[/] Output file is required. "
+ "Use --output/-o or --inplace.",
+ )
+ raise typer.Exit(code=1)
+
+ if layout is not None and layout not in LAYOUTS:
+ console.print(
+ f"[bold red]Error:[/] --layout must be one of: {', '.join(LAYOUTS)}."
+ )
+ raise typer.Exit(code=1)
+
+ if zarr_format is not None and zarr_format not in (2, 3):
+ console.print("[bold red]Error:[/] --zarr-format must be 2 or 3.")
+ raise typer.Exit(code=1)
+
+ try:
+ convert_store(
+ file=file,
+ entries=list(entries) if entries else None,
+ output=output,
+ console=console,
+ dtype=dtype,
+ indices_dtype=indices_dtype,
+ layout=layout,
+ convert_all=convert_all,
+ inplace=inplace,
+ chunk_rows=chunk_rows,
+ in_memory=in_memory,
+ force=force,
+ zarr_format=zarr_format,
+ )
+ except Exception as e:
+ console.print(f"[bold red]Error:[/] {e}")
+ raise typer.Exit(code=1)
+
+
# ============================================================================
# SPLIT command
# ============================================================================
diff --git a/src/adata/commands/__init__.py b/src/adata/commands/__init__.py
index 70e0690..5ed2e4c 100644
--- a/src/adata/commands/__init__.py
+++ b/src/adata/commands/__init__.py
@@ -5,4 +5,5 @@
from adata.commands.ls import list_store
from adata.commands.create import create_store
from adata.commands.split import split_store
-from adata.commands.concat import MERGE_STRATEGIES, concat_stores
+from adata.commands.concat import MERGE_CHOICES, MERGE_STRATEGIES, concat_stores
+from adata.commands.convert import convert_store
diff --git a/src/adata/commands/concat.py b/src/adata/commands/concat.py
index 650b854..befda91 100644
--- a/src/adata/commands/concat.py
+++ b/src/adata/commands/concat.py
@@ -7,9 +7,9 @@
from rich.console import Console
-from adata.core.concat import MERGE_STRATEGIES, concat_on_disk
+from adata.core.concat import MERGE_CHOICES, MERGE_STRATEGIES, concat_on_disk
-__all__ = ["MERGE_STRATEGIES", "concat_stores"]
+__all__ = ["MERGE_CHOICES", "MERGE_STRATEGIES", "concat_stores"]
def concat_stores(
diff --git a/src/adata/commands/convert.py b/src/adata/commands/convert.py
new file mode 100644
index 0000000..54844ff
--- /dev/null
+++ b/src/adata/commands/convert.py
@@ -0,0 +1,218 @@
+"""The `convert` command: rewrite matrices, copy everything else.
+
+Writes a whole new store rather than editing one in place, for the same
+reason `subset` does: a conversion that fails half way leaves the original
+untouched, and `--inplace` becomes an atomic swap of a finished file rather
+than a partial edit of a live one.
+"""
+
+from __future__ import annotations
+
+import shutil
+from pathlib import Path
+from typing import Any, List, Optional
+
+from rich.console import Console
+
+from adata.core.convert import (
+ DEFAULT_CHUNK,
+ LAYOUTS,
+ Plan,
+ convert_matrix,
+ parse_dtype,
+ plan_conversion,
+)
+from adata.elements import spec
+from adata.elements.write import ensure_anndata_skeleton
+from adata.storage import copy_tree, detect_backend, is_group, open_store
+
+#: What `--all` means. The matrices an AnnData object is built from, not
+#: every 2-D array in the file: obsm/varm hold embeddings, whose dtype is
+#: rarely what anyone is trying to shrink. Those still convert by path.
+ALL_PREFIXES = ("X", "layers", "raw/X")
+
+
+def discover_matrices(root: Any) -> List[str]:
+ """Entry paths `--all` resolves to, in a stable order."""
+ found: List[str] = []
+ if "X" in root:
+ found.append("X")
+ if "layers" in root and is_group(root["layers"]):
+ found.extend(f"layers/{key}" for key in sorted(root["layers"].keys()))
+ if "raw" in root and is_group(root["raw"]) and "X" in root["raw"]:
+ found.append("raw/X")
+ return found
+
+
+def _same_path(left: Path, right: Path) -> bool:
+ """Do these name the same store, through symlinks and `..` alike?"""
+ try:
+ return left.resolve() == right.resolve()
+ except OSError: # pragma: no cover - unresolvable path
+ return left.absolute() == right.absolute()
+
+
+def _resolve(root: Any, path: str) -> Any:
+ obj = root
+ for part in path.split("/"):
+ if part not in obj:
+ raise KeyError(f"{path!r} not found in the store.")
+ obj = obj[part]
+ return obj
+
+
+def convert_store(
+ file: Path,
+ entries: Optional[List[str]],
+ output: Optional[Path],
+ console: Console,
+ *,
+ dtype: Optional[str] = None,
+ indices_dtype: Optional[str] = None,
+ layout: Optional[str] = None,
+ convert_all: bool = False,
+ inplace: bool = False,
+ chunk: int = DEFAULT_CHUNK,
+ chunk_rows: int = 1024,
+ in_memory: bool = False,
+ force: bool = False,
+ zarr_format: Optional[int] = None,
+) -> None:
+ """Write a copy of `file` with the named matrices converted."""
+ if not inplace and output is None:
+ raise ValueError("Output file is required unless --inplace is specified.")
+ if dtype is None and indices_dtype is None and layout is None:
+ raise ValueError(
+ "Nothing to do: pass at least one of --dtype, --indices-dtype "
+ "or --layout."
+ )
+ if layout is not None and layout not in LAYOUTS:
+ raise ValueError(f"--layout must be one of: {', '.join(LAYOUTS)}.")
+
+ data_dtype = parse_dtype(dtype) if dtype else None
+ index_dtype = (
+ parse_dtype(indices_dtype, allowed=("int32", "int64"))
+ if indices_dtype
+ else None
+ )
+
+ if output is not None and not inplace and _same_path(file, output):
+ # Opening the destination "w" clears it while the source is still
+ # being read from it. HDF5 refuses; Zarr does not, and quietly
+ # produced an empty store where the data used to be.
+ raise ValueError(
+ f"Output path is the input: {output}. Use --inplace to replace "
+ "it, which writes to a temporary file first."
+ )
+
+ if inplace:
+ backend = detect_backend(file)
+ if backend == "zarr":
+ base = file.stem if file.suffix else file.name
+ dst_path = file.with_name(f"{base}.convert-tmp.zarr")
+ else:
+ dst_path = file.with_name(f"{file.name}.convert-tmp")
+ if dst_path.exists():
+ raise FileExistsError(f"Temporary path already exists: {dst_path}")
+ else:
+ dst_path = output
+
+ if zarr_format is None and detect_backend(file) == "zarr":
+ with open_store(file, "r") as probe:
+ zarr_format = probe.zarr_format
+
+ with open_store(file, "r") as src_store:
+ src = src_store.root
+ targets = discover_matrices(src) if convert_all else list(entries or [])
+ if not targets:
+ raise ValueError(
+ "No matrices selected. Name one (e.g. `X`) or pass --all."
+ )
+ # Every check first, before the destination exists. A refusal must
+ # not leave behind a store holding a copy of obs and var and nothing
+ # else -- the caller cannot tell that from a finished conversion.
+ plans = {
+ path: plan_conversion(
+ _resolve(src, path),
+ path,
+ dtype=data_dtype,
+ index_dtype=index_dtype,
+ layout=layout,
+ chunk=chunk,
+ force=force,
+ console=console,
+ )
+ for path in targets
+ }
+
+ console.print(
+ f"[cyan]Converting {len(targets)} matrix/matrices:[/] "
+ + ", ".join(targets)
+ )
+
+ with open_store(dst_path, "w", zarr_format=zarr_format) as dst_store:
+ dst = dst_store.root
+ _write(
+ src, dst, plans,
+ chunk=chunk, chunk_rows=chunk_rows, in_memory=in_memory,
+ console=console,
+ )
+ ensure_anndata_skeleton(dst)
+
+ if inplace:
+ if file.is_dir():
+ shutil.rmtree(file)
+ elif file.exists():
+ file.unlink()
+ if dst_path.is_dir():
+ shutil.move(str(dst_path), str(file))
+ else:
+ dst_path.replace(file)
+ console.print(f"[green]Converted[/] {file}")
+ else:
+ console.print(f"[green]Wrote[/] {dst_path}")
+
+
+def _write(src: Any, dst: Any, plans: dict, **options: Any) -> None:
+ """Copy the store across, converting the targeted entries as they pass."""
+ by_parent: dict = {}
+ for path in plans:
+ parent, _, leaf = path.rpartition("/")
+ by_parent.setdefault(parent, {})[leaf] = plans[path]
+
+ for key in src.keys():
+ if key in by_parent.get("", {}):
+ convert_matrix(
+ src[key], dst, key, plan=by_parent[""][key], **options
+ )
+ elif key in by_parent:
+ # Any parent, not just layers and raw. Restricting it to those
+ # two meant an explicitly named `obsm/X_pca` was copied
+ # unconverted and the command still reported success.
+ _write_group(src[key], dst, key, by_parent, **options)
+ else:
+ copy_tree(src[key], dst, key)
+
+
+def _write_group(
+ group: Any, dst: Any, name: str, by_parent: dict, **options: Any
+) -> None:
+ """Recreate one container, converting the members that were named."""
+ from adata.storage import copy_attrs, is_zarr_group
+
+ out = dst.create_group(name)
+ copy_attrs(
+ group.attrs,
+ out.attrs,
+ target_backend="zarr" if is_zarr_group(dst) else "hdf5",
+ )
+ if not spec.encoding_type(out):
+ spec.set_encoding(out, spec.RAW if name == "raw" else spec.DICT)
+
+
+ wanted = by_parent.get(name, {})
+ for key in group.keys():
+ if key in wanted:
+ convert_matrix(group[key], out, key, plan=wanted[key], **options)
+ else:
+ copy_tree(group[key], out, key)
diff --git a/src/adata/core/concat.py b/src/adata/core/concat.py
index 4982916..39aa7ea 100644
--- a/src/adata/core/concat.py
+++ b/src/adata/core/concat.py
@@ -48,6 +48,8 @@
)
MERGE_STRATEGIES = ("same", "unique", "first", "only")
+#: What the CLI accepts. "drop" is the default and maps to no merge at all.
+MERGE_CHOICES = ("drop",) + MERGE_STRATEGIES
def _index_union(per_input: Sequence[List[str]]) -> List[str]:
@@ -174,6 +176,16 @@ def __repr__(self) -> str: # pragma: no cover - debugging aid
_MISSING = _Missing()
+class _Present:
+ """Stands for a column whose value was not read, only its presence."""
+
+ def __repr__(self) -> str: # pragma: no cover - debugging aid
+ return ""
+
+
+_PRESENT = _Present()
+
+
def _equal(a: Any, b: Any) -> bool:
if isinstance(a, _Incomparable) or isinstance(b, _Incomparable):
return False
@@ -212,15 +224,20 @@ def _concat_categorical(
concatenated directly. A row from an input lacking the column gets code
-1, which anndata reads back as a missing value.
"""
- categories: List[str] = []
+ # Hash lookup, not list membership. `if category not in categories` on a
+ # list is O(k) per probe and so O(k^2) over the union -- around 2 million
+ # string comparisons at 1024 categories, and 5 billion at 100k. It reads
+ # the same number of bytes either way, so only a comparison count sees it.
+ lookup: Dict[str, int] = {}
for col in columns:
if col is None:
continue
for category in read_categories(col):
- if category not in categories:
- categories.append(str(category))
+ text = str(category)
+ if text not in lookup:
+ lookup[text] = len(lookup)
- lookup = {c: i for i, c in enumerate(categories)}
+ categories = list(lookup)
ordered = all(is_ordered(c) for c in columns if c is not None)
codes = np.full(sum(lengths), -1, dtype=np.int64)
@@ -277,33 +294,35 @@ def _concat_masked(
total = sum(lengths)
mask = np.ones(total, dtype=bool)
- values: List[Any] = [None] * total
+ present = [c for c in columns if c is not None]
+
+ # Fill a typed buffer by slice, rather than a Python list of length
+ # n_obs one element at a time. The list cost an object per row and three
+ # full passes over it, which is what made obs concatenation the most
+ # allocation-hungry part of a streamed concat.
+ if enc == spec.NULLABLE_STRING_ARRAY:
+ from adata.elements.read import decode_str_array
+
+ filled = np.empty(total, dtype=object)
+ filled[:] = ""
+ else:
+ decode_str_array = None
+ dtype = np.result_type(*[c["values"].dtype for c in present])
+ filled = np.zeros(total, dtype=dtype)
offset = 0
for col, length in zip(columns, lengths):
if col is not None:
chunk = np.asarray(col["values"][...])
- chunk_mask = np.asarray(col["mask"][...], dtype=bool)
- for i in range(length):
- values[offset + i] = chunk[i]
- mask[offset : offset + length] = chunk_mask
+ if decode_str_array is not None:
+ chunk = decode_str_array(chunk)
+ filled[offset : offset + length] = chunk
+ mask[offset : offset + length] = np.asarray(
+ col["mask"][...], dtype=bool
+ )
offset += length
- if enc == spec.NULLABLE_STRING_ARRAY:
- from adata.elements.read import decode_str_array
-
- filled = [
- "" if v is None else decode_str_array(np.asarray([v]))[0] for v in values
- ]
- write_masked(parent, name, filled, mask, enc)
- return
-
- present = [c for c in columns if c is not None]
- dtype = np.result_type(*[c["values"].dtype for c in present])
- filled_num = np.array(
- [0 if v is None else v for v in values], dtype=dtype
- )
- write_masked(parent, name, filled_num, mask, enc)
+ write_masked(parent, name, filled, mask, enc)
def _concat_string(
@@ -542,10 +561,13 @@ def _check(label: str, sources: List[Any]) -> None:
kinds = {_matrix_kind(s) for s in sources}
if kinds in ({spec.CSR_MATRIX}, {spec.CSC_MATRIX}, {"dense"}):
return
+ wanted = sorted(kinds)[0]
raise ValueError(
f"Cannot concatenate {label!r}: inputs use "
f"{', '.join(sorted(kinds))}. Every input must use the same "
- "encoding -- convert them to match first."
+ f"encoding. Convert them to match first, e.g. "
+ f"`adata convert INPUT {label} -o converted.h5ad --layout "
+ f"{'dense' if wanted == 'dense' else wanted.removesuffix('_matrix')}`."
)
if all("X" in r for r in roots):
@@ -605,6 +627,9 @@ def _concat_matrix(
)
return True
+ # A backstop. `check_matrix_encodings` runs before the output store is
+ # created and should have raised already; this catches an element it
+ # does not cover, where failing late still beats writing nonsense.
raise ValueError(
f"Cannot concatenate {name!r}: inputs use {', '.join(sorted(kinds))}."
)
@@ -945,13 +970,25 @@ def _write_var(
positions = [_column_map(target_var, names) for names in var_names]
written: List[str] = []
+ # "first" and "only" decide on presence alone, so the column values are
+ # never read for them.
+ compares = merge in ("same", "unique")
+
for name in candidates:
aligned: List[Any] = []
for group, where in zip(groups, positions):
if name not in group or (where < 0).any():
aligned.append(_MISSING)
continue
- aligned.append(tuple(read_str_all(group[name])[i] for i in where))
+ if not compares:
+ aligned.append(_PRESENT)
+ continue
+ # Read the column once and index the result. Reading it inside the
+ # generator -- as an earlier version did -- re-read the whole
+ # column for every target variable, which is quadratic and turns a
+ # 36k-var merge into hours of pure CPU.
+ values = read_str_all(group[name])
+ aligned.append(tuple(values[i] for i in where))
keep, _ = _merge_values(aligned, merge)
if not keep:
diff --git a/src/adata/core/convert.py b/src/adata/core/convert.py
new file mode 100644
index 0000000..fb3cfd3
--- /dev/null
+++ b/src/adata/core/convert.py
@@ -0,0 +1,906 @@
+"""Rewriting a matrix's dtype, layout or density, on disk.
+
+Three conversions, one command. Counts stored as float64 cost twice the disk
+and twice the read for no information (issue #13); a CSR store handed to a
+tool that wants CSC has to be transposed somewhere; and `concat` refuses
+inputs whose encodings disagree, which until now left nothing to do about it.
+
+Everything here streams. The matrix these conversions matter for is the one
+too large to load, so a converter that loads it would only work on the files
+that did not need converting.
+
+Two safety rules, both of which fail before anything is written -- the same
+principle `check_matrix_encodings` states for concat, because a half-written
+store is worse than a refusal:
+
+* a cast that does not round-trip is refused unless forced, so `int32`
+ indices that would overflow, or a float downcast that would lose real
+ precision, are reported rather than silently written;
+* densifying is refused when it would inflate the store beyond
+ `MAX_GROWTH_FACTOR`, because a 5% dense matrix becomes ten times its size
+ and that should not be a surprise.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any, Dict, List, Optional, Tuple
+
+import numpy as np
+from rich.console import Console
+
+from adata.elements import spec
+from adata.elements.write import set_shape_attr
+from adata.storage import (
+ copy_attrs,
+ create_dataset,
+ dataset_create_kwargs,
+ is_dataset,
+ is_group,
+ is_zarr_group,
+)
+
+#: Layouts the user can ask for. "sparse" means "whichever sparse encoding
+#: keeps the major axis it already has", so densify/sparsify round-trips.
+LAYOUTS = ("csr", "csc", "dense", "sparse")
+
+#: What `--dtype` accepts. An allowlist rather than `np.dtype(text)`: that
+#: would cheerfully accept "S10" or "datetime64[ns]" and produce a store no
+#: reader expects from a matrix.
+DTYPES = (
+ "float16", "float32", "float64",
+ "int8", "int16", "int32", "int64",
+ "uint8", "uint16", "uint32", "uint64",
+ "bool",
+)
+
+#: Indices and indptr must be integers, and signed: anndata and scipy both
+#: expect a signed index type.
+INDEX_DTYPES = ("int32", "int64")
+
+#: How much larger a densified store may get before it needs --force.
+MAX_GROWTH_FACTOR = 4.0
+
+#: Values per read while streaming `data`/`indices`.
+DEFAULT_CHUNK = 1 << 20
+
+#: Smallest bucket worth making during a streaming transpose. Below this
+#: the per-bucket overhead dominates and the extra passes buy nothing.
+MIN_BUCKET_ENTRIES = 1 << 12
+
+
+def parse_dtype(text: str, *, allowed: Tuple[str, ...] = DTYPES) -> np.dtype:
+ """Resolve a user-supplied dtype name, or say what is accepted."""
+ name = text.strip().lower()
+ if name not in allowed:
+ raise ValueError(
+ f"Unknown dtype {text!r}. Choose from: {', '.join(allowed)}."
+ )
+ return np.dtype(name)
+
+
+# ---------------------------------------------------------------------------
+# safety
+
+
+@dataclass
+class CastReport:
+ """What a cast would do to the values, measured rather than assumed."""
+
+ total: int = 0
+ changed: int = 0
+ worst_absolute: float = 0.0
+ overflowed: bool = False
+
+ @property
+ def lossless(self) -> bool:
+ return self.changed == 0 and not self.overflowed
+
+ def describe(self, source: Any, target: Any) -> str:
+ if self.lossless:
+ return f"{source} -> {target}: every value round-trips"
+ share = self.changed / self.total if self.total else 0.0
+ detail = (
+ "values exceed its range"
+ if self.overflowed
+ else f"largest change {self.worst_absolute:g}"
+ )
+ return (
+ f"{source} -> {target} is lossy: {self.changed:,} of "
+ f"{self.total:,} values ({share:.2%}) do not round-trip, {detail}"
+ )
+
+
+def check_cast(dataset: Any, target: np.dtype, *, chunk: int = DEFAULT_CHUNK) -> CastReport:
+ """Would casting `dataset` to `target` lose anything?
+
+ Streams the values, casts each block and casts it back. An exact
+ round-trip is the only honest test: whether float64 counts survive
+ float32 depends on the counts, not on the dtypes, and that is precisely
+ the question issue #13 is asking.
+ """
+ report = CastReport()
+ source = np.dtype(getattr(dataset, "dtype", "float64"))
+ n = int(dataset.shape[0]) if getattr(dataset, "shape", None) else 0
+
+ info = np.finfo(target) if target.kind == "f" else (
+ np.iinfo(target) if target.kind in "iu" else None
+ )
+
+ for start in range(0, n, chunk):
+ block = np.asarray(dataset[start : min(start + chunk, n)])
+ report.total += block.size
+ if block.size == 0:
+ continue
+
+ with np.errstate(invalid="ignore", over="ignore"):
+ cast = block.astype(target)
+ back = cast.astype(source)
+
+ if info is not None:
+ finite = block[np.isfinite(block)] if source.kind == "f" else block
+ if finite.size and (
+ float(finite.max()) > float(info.max)
+ or float(finite.min()) < float(info.min)
+ ):
+ report.overflowed = True
+
+ # NaN never equals itself, so compare those separately rather than
+ # counting every missing value as a loss.
+ differs = back != block
+ if source.kind == "f":
+ both_nan = np.isnan(block) & np.isnan(back)
+ differs &= ~both_nan
+ count = int(differs.sum())
+ if count:
+ report.changed += count
+ delta = np.abs(
+ block[differs].astype("float64") - back[differs].astype("float64")
+ )
+ finite_delta = delta[np.isfinite(delta)]
+ if finite_delta.size:
+ report.worst_absolute = max(
+ report.worst_absolute, float(finite_delta.max())
+ )
+
+ return report
+
+
+def check_index_dtype(
+ group: Any,
+ index_dtype: np.dtype,
+ pointer_dtype: np.dtype,
+ shape: Tuple[int, int],
+) -> None:
+ """Refuse index dtypes that cannot address this matrix.
+
+ Cheaper than `check_cast`: the largest value each array must hold is
+ bounded by the dimensions and the nonzero count, so no pass over the
+ data is needed. `indices` holds coordinates, bounded by the larger
+ dimension; `indptr` holds offsets, bounded by nnz. They are checked
+ separately because they can legitimately need different widths.
+ """
+ nnz = int(group["indices"].shape[0])
+ for name, dtype, largest, what in (
+ ("indices", index_dtype, max(int(shape[0]), int(shape[1])), "coordinates"),
+ ("indptr", pointer_dtype, nnz, "offsets"),
+ ):
+ limit = int(np.iinfo(dtype).max)
+ if largest > limit:
+ raise ValueError(
+ f"{dtype} cannot hold this matrix's {what}: {name} must reach "
+ f"{largest:,} for a {shape[0]:,} x {shape[1]:,} matrix with "
+ f"{nnz:,} nonzeros, and {dtype} tops out at {limit:,}. "
+ "Use int64."
+ )
+
+
+def _stored_bytes(obj: Any) -> int:
+ """Bytes this element occupies, as best the backend will say."""
+ total = 0
+ targets = [obj] if is_dataset(obj) else [
+ obj[k] for k in ("data", "indices", "indptr") if k in obj
+ ]
+ for item in targets:
+ try:
+ total += int(item.nbytes)
+ except Exception: # pragma: no cover - backend without nbytes
+ shape = getattr(item, "shape", ()) or ()
+ size = int(np.prod(shape)) if shape else 0
+ total += size * int(getattr(item.dtype, "itemsize", 8) or 8)
+ return total
+
+
+def check_growth(
+ current: int, projected: int, *, force: bool, what: str
+) -> None:
+ """Refuse a conversion that inflates the store, unless asked twice."""
+ if force or current <= 0 or projected <= current * MAX_GROWTH_FACTOR:
+ return
+ raise ValueError(
+ f"{what} would grow from {current / 1e6:,.0f} MB to "
+ f"{projected / 1e6:,.0f} MB ({projected / current:.1f}x). That is "
+ f"above the {MAX_GROWTH_FACTOR:g}x limit; pass --force if it is what "
+ "you want."
+ )
+
+
+# ---------------------------------------------------------------------------
+# reading a source
+
+
+@dataclass
+class Matrix:
+ """A matrix on disk, described enough to convert it."""
+
+ obj: Any
+ kind: str # "csr_matrix" | "csc_matrix" | "dense"
+ shape: Tuple[int, int]
+ dtype: np.dtype
+
+ @property
+ def sparse(self) -> bool:
+ return self.kind in spec.SPARSE_TYPES
+
+ @property
+ def nnz(self) -> int:
+ return int(self.obj["indices"].shape[0]) if self.sparse else 0
+
+
+def describe(obj: Any) -> Matrix:
+ """Classify a matrix element, or say why it is not one."""
+ enc = spec.encoding_type(obj)
+ if is_group(obj) and enc in spec.SPARSE_TYPES:
+ shape = obj.attrs.get("shape", None)
+ if shape is None:
+ raise ValueError("Sparse matrix group is missing its 'shape' attribute.")
+ return Matrix(obj, enc, (int(shape[0]), int(shape[1])), obj["data"].dtype)
+
+ if is_dataset(obj):
+ if getattr(obj, "ndim", 0) != 2:
+ raise ValueError(
+ f"Only 2-D matrices can be converted; this one has "
+ f"{getattr(obj, 'ndim', '?')} dimension(s)."
+ )
+ return Matrix(obj, "dense", (int(obj.shape[0]), int(obj.shape[1])), obj.dtype)
+
+ raise ValueError(
+ f"Not a matrix: encoding {enc!r}. `convert` handles X, layers, raw/X "
+ "and any 2-D dense array."
+ )
+
+
+def resolve_layout(source: Matrix, requested: Optional[str]) -> str:
+ """The concrete target encoding for a possibly-vague request."""
+ if requested is None:
+ return source.kind
+ if requested == "dense":
+ return "dense"
+ if requested == "sparse":
+ # Keep the major axis it already had, so sparse -> dense -> sparse is
+ # the identity rather than a silent transpose.
+ return source.kind if source.sparse else spec.CSR_MATRIX
+ return spec.CSR_MATRIX if requested == "csr" else spec.CSC_MATRIX
+
+
+# ---------------------------------------------------------------------------
+# writers
+
+
+def _new_sparse_group(
+ src: Matrix, dst_parent: Any, name: str, enc: str, shape: Tuple[int, int]
+) -> Any:
+ group = dst_parent.create_group(name)
+ backend = "zarr" if is_zarr_group(dst_parent) else "hdf5"
+ copy_attrs(src.obj.attrs, group.attrs, target_backend=backend)
+ spec.set_encoding(group, enc)
+ set_shape_attr(group, shape)
+ return group
+
+
+def _sparse_dataset(
+ group: Any, name: str, dtype: np.dtype, n: int, template: Any
+) -> Any:
+ """A 1-D dataset of `n` elements laid out like `template`.
+
+ Forwarding compression and chunking matters more here than anywhere
+ else: the point of a dtype change is usually to make the file smaller,
+ and creating the output with a fixed 65,536-element chunk made a
+ 2,400-nonzero matrix allocate 786 KB of mostly empty chunk -- eight
+ times the source, from a conversion asked for to halve it.
+ """
+ from adata.core.subset import _clamp_chunks
+
+ backend = "zarr" if is_zarr_group(group) else "hdf5"
+ kw = dataset_create_kwargs(template, target_backend=backend, dst_parent=group)
+ kw = _clamp_chunks(kw, max(1, n))
+ if "chunks" not in kw and n:
+ kw["chunks"] = (min(n, 1 << 16),)
+ return create_dataset(group, name, shape=(n,), dtype=dtype, **kw)
+
+
+def _growable_like(group: Any, name: str, dtype: np.dtype, template: Any) -> Any:
+ """Like `_sparse_dataset`, but extensible for a size not yet known."""
+ backend = "zarr" if is_zarr_group(group) else "hdf5"
+ kw = dataset_create_kwargs(template, target_backend=backend, dst_parent=group)
+ kw.pop("shards", None)
+ chunks = kw.pop("chunks", None)
+ step = int(chunks[0]) if chunks else 1 << 16
+ if is_zarr_group(group):
+ return group.create_array(name, shape=(0,), dtype=dtype, chunks=(step,), **kw)
+ return group.create_dataset(
+ name, shape=(0,), maxshape=(None,), dtype=dtype, chunks=(step,), **kw
+ )
+
+
+def _write_sparse_arrays(
+ group: Any,
+ data: np.ndarray,
+ indices: np.ndarray,
+ indptr: np.ndarray,
+ *,
+ data_dtype: np.dtype,
+ index_dtype: np.dtype,
+ pointer_dtype: np.dtype,
+ source: Any,
+) -> None:
+ for name, values, dtype, template in (
+ ("data", data, data_dtype, source["data"]),
+ ("indices", indices, index_dtype, source["indices"]),
+ ("indptr", indptr, pointer_dtype, source["indptr"]),
+ ):
+ cast = values.astype(dtype, copy=False)
+ dataset = _sparse_dataset(group, name, dtype, cast.size, template)
+ if cast.size:
+ dataset[:] = cast
+
+
+def cast_sparse(
+ src: Matrix,
+ dst_parent: Any,
+ name: str,
+ *,
+ data_dtype: np.dtype,
+ index_dtype: np.dtype,
+ pointer_dtype: np.dtype,
+ chunk: int = DEFAULT_CHUNK,
+) -> None:
+ """Rewrite a sparse matrix with new dtypes, keeping its layout.
+
+ The cheap case, and the one issue #13 asks for: the structure is
+ untouched, so this is a straight streamed copy of `data` and `indices`
+ into differently typed datasets. Sized up front, because nnz is known.
+ """
+ group = _new_sparse_group(src, dst_parent, name, src.kind, src.shape)
+ source_data, source_indices = src.obj["data"], src.obj["indices"]
+ nnz = int(source_data.shape[0])
+
+ out_data = _sparse_dataset(group, "data", data_dtype, nnz, source_data)
+ out_indices = _sparse_dataset(group, "indices", index_dtype, nnz, source_indices)
+
+ for start in range(0, nnz, chunk):
+ end = min(start + chunk, nnz)
+ out_data[start:end] = np.asarray(source_data[start:end]).astype(
+ data_dtype, copy=False
+ )
+ out_indices[start:end] = np.asarray(source_indices[start:end]).astype(
+ index_dtype, copy=False
+ )
+
+ indptr = np.asarray(src.obj["indptr"][...]).astype(pointer_dtype, copy=False)
+ out_indptr = _sparse_dataset(
+ group, "indptr", pointer_dtype, indptr.size, src.obj["indptr"]
+ )
+ out_indptr[:] = indptr
+
+
+def transpose_sparse_in_memory(
+ src: Matrix,
+ dst_parent: Any,
+ name: str,
+ *,
+ data_dtype: np.dtype,
+ index_dtype: np.dtype,
+ pointer_dtype: np.dtype = np.dtype("int64"),
+) -> None:
+ """Swap CSR<->CSC by loading the matrix and sorting it once.
+
+ Faster than streaming whenever the matrix fits, and the whole matrix is
+ what it needs -- so it is opt-in, never the default.
+ """
+ target = (
+ spec.CSC_MATRIX if src.kind == spec.CSR_MATRIX else spec.CSR_MATRIX
+ )
+ n_major_in = src.shape[0] if src.kind == spec.CSR_MATRIX else src.shape[1]
+ n_major_out = src.shape[1] if src.kind == spec.CSR_MATRIX else src.shape[0]
+
+ indptr = np.asarray(src.obj["indptr"][...], dtype=np.int64)
+ minor = np.asarray(src.obj["indices"][...], dtype=np.int64)
+ values = np.asarray(src.obj["data"][...])
+ major = np.repeat(np.arange(n_major_in, dtype=np.int64), np.diff(indptr))
+
+ # Sort by the new major axis, then the new minor, which is what both
+ # encodings require of `indices` within a row.
+ order = np.lexsort((major, minor))
+ out_indptr = np.concatenate(
+ ([0], np.cumsum(np.bincount(minor, minlength=n_major_out)))
+ )
+
+ group = _new_sparse_group(src, dst_parent, name, target, src.shape)
+ _write_sparse_arrays(
+ group,
+ values[order],
+ major[order],
+ out_indptr,
+ data_dtype=data_dtype,
+ index_dtype=index_dtype,
+ pointer_dtype=pointer_dtype,
+ source=src.obj,
+ )
+
+
+def transpose_sparse_streaming(
+ src: Matrix,
+ dst_parent: Any,
+ name: str,
+ *,
+ data_dtype: np.dtype,
+ index_dtype: np.dtype,
+ pointer_dtype: np.dtype = np.dtype("int64"),
+ chunk: int = DEFAULT_CHUNK,
+ bucket_entries: Optional[int] = None,
+ console: Optional[Console] = None,
+) -> None:
+ """Swap CSR<->CSC without loading the matrix.
+
+ A transpose cannot be done in one pass: the first entry of the output
+ may come from the last row of the input. Three passes instead, with
+ memory set by `bucket_entries` rather than by nnz:
+
+ 1. count nonzeros per output major, by streaming `indices` alone. That
+ gives the output `indptr` by cumulative sum.
+ 2. stream the input again, splitting each block's entries into buckets by
+ which slice of the output they land in, and append each bucket to
+ scratch datasets.
+ 3. read one bucket at a time, sort it, and append to the output in order.
+
+ Costs about two extra passes over nnz, which is the price of not holding
+ the matrix. `--in-memory` is there for when you would rather pay in RAM.
+ """
+ from adata.core.subset import _append, _growable
+
+ target = (
+ spec.CSC_MATRIX if src.kind == spec.CSR_MATRIX else spec.CSR_MATRIX
+ )
+ n_major_in = src.shape[0] if src.kind == spec.CSR_MATRIX else src.shape[1]
+ n_major_out = src.shape[1] if src.kind == spec.CSR_MATRIX else src.shape[0]
+
+ indptr = np.asarray(src.obj["indptr"][...], dtype=np.int64)
+ source_indices, source_data = src.obj["indices"], src.obj["data"]
+ nnz = int(source_indices.shape[0])
+
+ # Pass 1: how many entries land in each output major.
+ counts = np.zeros(n_major_out, dtype=np.int64)
+ for start in range(0, nnz, chunk):
+ block = np.asarray(source_indices[start : min(start + chunk, nnz)], dtype=np.int64)
+ counts += np.bincount(block, minlength=n_major_out)
+ out_indptr = np.concatenate(([0], np.cumsum(counts)))
+
+ # One bucket per slice of the output major axis, holding about as many
+ # nonzeros as one read. Tying this to `chunk` rather than a constant is
+ # what makes the peak follow the setting the caller chose: with a fixed
+ # bucket size, anything below it went into a single bucket and the
+ # "streaming" path quietly held the whole matrix.
+ # An explicit request is honoured as given; the floor applies only to
+ # the value derived from `chunk`, where it stops a tiny chunk producing
+ # thousands of buckets whose overhead outweighs the saving.
+ per_bucket = (
+ max(1, int(bucket_entries))
+ if bucket_entries
+ else max(MIN_BUCKET_ENTRIES, int(chunk))
+ )
+ n_buckets = max(1, int(np.ceil(nnz / per_bucket))) if nnz else 1
+ n_buckets = min(n_buckets, n_major_out) or 1
+
+ # Split by nonzero count, not by coordinate. Equal-width bounds put
+ # nearly everything in one bucket whenever the matrix is skewed -- and
+ # single-cell matrices are: a handful of genes carry most of the
+ # counts. Measured on one such matrix, the largest of three equal-width
+ # buckets held 88% of the entries, so the bucket, not the chunk, set
+ # the peak. `counts` is already to hand from pass 1.
+ cumulative = out_indptr
+ targets = np.linspace(0, nnz, n_buckets + 1)[1:-1]
+ bounds = np.concatenate((
+ [0],
+ np.searchsorted(cumulative, targets, side="left").astype(np.int64),
+ [n_major_out],
+ ))
+ bounds = np.unique(bounds)
+ n_buckets = len(bounds) - 1
+ if console is not None and n_buckets > 1:
+ console.print(
+ f"[dim]Transposing {nnz:,} nonzeros through {n_buckets} buckets[/]"
+ )
+
+ # nnz is invariant under a transpose, so the output can be sized now and
+ # written by slice rather than grown block by block.
+ group = _new_sparse_group(src, dst_parent, name, target, src.shape)
+ out_data = _sparse_dataset(group, "data", data_dtype, nnz, source_data)
+ out_indices = _sparse_dataset(group, "indices", index_dtype, nnz, source_indices)
+ written = 0
+
+ scratch_name = f"__{name}_transpose_scratch__"
+ scratch = dst_parent.create_group(scratch_name)
+ try:
+ buckets = [
+ (
+ _growable(scratch, f"major{b}", np.int64),
+ _growable(scratch, f"minor{b}", np.int64),
+ _growable(scratch, f"value{b}", src.dtype),
+ )
+ for b in range(n_buckets)
+ ]
+
+ # Pass 2: scatter the input into buckets, one input block at a time.
+ # Step over the major axis in blocks holding about `chunk` nonzeros,
+ # so the read size is set by the data rather than by how many rows
+ # happen to be empty.
+ average = max(1, nnz // max(1, n_major_in))
+ major_step = max(1, chunk // average)
+ for lo in range(0, n_major_in, major_step):
+ hi = min(lo + major_step, n_major_in)
+ start, end = int(indptr[lo]), int(indptr[hi])
+ if end <= start:
+ continue
+ minor = np.asarray(source_indices[start:end], dtype=np.int64)
+ values = np.asarray(source_data[start:end])
+ major = np.repeat(
+ np.arange(lo, hi, dtype=np.int64), np.diff(indptr[lo : hi + 1])
+ )
+ which = np.clip(np.searchsorted(bounds, minor, side="right") - 1, 0, n_buckets - 1)
+ for b in range(n_buckets):
+ pick = which == b
+ if not pick.any():
+ continue
+ _append(buckets[b][0], minor[pick])
+ _append(buckets[b][1], major[pick])
+ _append(buckets[b][2], values[pick])
+
+ # Pass 3: each bucket in turn, sorted into output order.
+ for b in range(n_buckets):
+ new_major, new_minor, value = buckets[b]
+ if new_major.shape[0] == 0:
+ continue
+ majors = np.asarray(new_major[...], dtype=np.int64)
+ minors = np.asarray(new_minor[...], dtype=np.int64)
+ values = np.asarray(value[...])
+ order = np.lexsort((minors, majors))
+ count = order.size
+ out_indices[written : written + count] = minors[order].astype(
+ index_dtype, copy=False
+ )
+ out_data[written : written + count] = values[order].astype(
+ data_dtype, copy=False
+ )
+ written += count
+ finally:
+ del dst_parent[scratch_name]
+
+ cast_indptr = out_indptr.astype(pointer_dtype, copy=False)
+ dataset = _sparse_dataset(
+ group, "indptr", pointer_dtype, cast_indptr.size, src.obj["indptr"]
+ )
+ dataset[:] = cast_indptr
+
+
+def densify(
+ src: Matrix,
+ dst_parent: Any,
+ name: str,
+ *,
+ data_dtype: np.dtype,
+ chunk_rows: int = 1024,
+) -> None:
+ """Write a sparse matrix out as a dense array, a block of rows at a time.
+
+ The zeros are the point: nothing here materialises the whole grid, so a
+ matrix too large to densify in memory still converts -- it just produces
+ a file that is honestly much larger, which `check_growth` warns about
+ before any of it is written.
+ """
+ n_rows, n_cols = src.shape
+ backend = "zarr" if is_zarr_group(dst_parent) else "hdf5"
+ dst = create_dataset(
+ dst_parent, name, shape=(n_rows, n_cols), dtype=data_dtype
+ )
+ copy_attrs(src.obj.attrs, dst.attrs, target_backend=backend)
+ spec.set_encoding(dst, spec.ARRAY)
+ # `shape` belongs to the sparse encoding and would contradict the array's
+ # own shape if it were carried over.
+ if "shape" in dst.attrs:
+ del dst.attrs["shape"]
+
+ indptr = np.asarray(src.obj["indptr"][...], dtype=np.int64)
+ indices, values = src.obj["indices"], src.obj["data"]
+ csr = src.kind == spec.CSR_MATRIX
+ n_major = n_rows if csr else n_cols
+
+ for lo in range(0, n_major, chunk_rows):
+ hi = min(lo + chunk_rows, n_major)
+ start, end = int(indptr[lo]), int(indptr[hi])
+ block = np.zeros(
+ (hi - lo, n_cols) if csr else (n_rows, hi - lo), dtype=data_dtype
+ )
+ if end > start:
+ minor = np.asarray(indices[start:end], dtype=np.int64)
+ data = np.asarray(values[start:end])
+ major = np.repeat(
+ np.arange(hi - lo, dtype=np.int64), np.diff(indptr[lo : hi + 1])
+ )
+ # Repeated coordinates are legal in a CSR/CSC store and mean
+ # their sum, which is what scipy's own `toarray` produces.
+ # Plain assignment keeps whichever came last, so a
+ # non-canonical input silently changed value on densifying.
+ if csr:
+ np.add.at(block, (major, minor), data)
+ else:
+ np.add.at(block, (minor, major), data)
+ if csr:
+ dst[lo:hi, :] = block
+ else:
+ dst[:, lo:hi] = block
+
+
+def sparsify(
+ src: Matrix,
+ dst_parent: Any,
+ name: str,
+ *,
+ enc: str,
+ data_dtype: np.dtype,
+ index_dtype: np.dtype,
+ pointer_dtype: np.dtype = np.dtype("int64"),
+ chunk_rows: int = 1024,
+ console: Optional[Console] = None,
+) -> Tuple[int, float]:
+ """Write a dense array out as CSR or CSC, a block at a time.
+
+ Returns `(nnz, density)` so the caller can say whether it was worth it --
+ a matrix that is half nonzero gets bigger, not smaller, and the user
+ should hear that from us rather than from `du`.
+ """
+ from adata.core.subset import _append, _growable
+
+ n_rows, n_cols = src.shape
+ csr = enc == spec.CSR_MATRIX
+ n_major = n_rows if csr else n_cols
+
+ group = dst_parent.create_group(name)
+ backend = "zarr" if is_zarr_group(dst_parent) else "hdf5"
+ copy_attrs(src.obj.attrs, group.attrs, target_backend=backend)
+ spec.set_encoding(group, enc)
+ set_shape_attr(group, src.shape)
+
+ out_data = _growable_like(group, "data", data_dtype, src.obj)
+ out_indices = _growable_like(group, "indices", index_dtype, src.obj)
+ counts: List[int] = []
+
+ for lo in range(0, n_major, chunk_rows):
+ hi = min(lo + chunk_rows, n_major)
+ block = np.asarray(src.obj[lo:hi, :] if csr else src.obj[:, lo:hi])
+ if not csr:
+ block = block.T # iterate majors as rows either way
+ nonzero_major, nonzero_minor = np.nonzero(block)
+ counts.extend(np.bincount(nonzero_major, minlength=hi - lo).tolist())
+ _append(out_indices, nonzero_minor.astype(index_dtype, copy=False))
+ _append(out_data, block[nonzero_major, nonzero_minor].astype(
+ data_dtype, copy=False
+ ))
+
+ indptr = np.concatenate(([0], np.cumsum(counts, dtype=np.int64)))
+ create_dataset(group, "indptr", data=indptr.astype(pointer_dtype, copy=False))
+
+
+ nnz = int(indptr[-1])
+ density = nnz / max(1, n_rows * n_cols)
+ if console is not None and density > 0.5:
+ console.print(
+ f"[yellow]{name} is {density:.0%} nonzero; the sparse form is "
+ f"larger than the dense one below about 33%.[/]"
+ )
+ return nnz, density
+
+
+def cast_dense(
+ src: Matrix,
+ dst_parent: Any,
+ name: str,
+ *,
+ data_dtype: np.dtype,
+ chunk_rows: int = 1024,
+) -> None:
+ """Rewrite a dense matrix with a new dtype, a block of rows at a time."""
+ n_rows, n_cols = src.shape
+ backend = "zarr" if is_zarr_group(dst_parent) else "hdf5"
+ kw = dataset_create_kwargs(
+ src.obj, target_backend=backend, dst_parent=dst_parent
+ )
+ from adata.core.subset import _clamp_chunks
+
+ dst = create_dataset(
+ dst_parent,
+ name,
+ shape=(n_rows, n_cols),
+ dtype=data_dtype,
+ **_clamp_chunks(kw, n_rows, n_cols),
+ )
+ copy_attrs(src.obj.attrs, dst.attrs, target_backend=backend)
+
+ for lo in range(0, n_rows, chunk_rows):
+ hi = min(lo + chunk_rows, n_rows)
+ dst[lo:hi, :] = np.asarray(src.obj[lo:hi, :]).astype(
+ data_dtype, copy=False
+ )
+
+
+# ---------------------------------------------------------------------------
+# dispatch
+
+
+@dataclass
+class Plan:
+ """A checked conversion, ready to run.
+
+ Separating the decision from the writing is what makes "fails before
+ anything is written" true rather than aspirational: the caller resolves
+ every plan first, and only then creates the destination store. An
+ earlier version checked inside the writer, so a refusal still left an
+ output file holding a copy of obs and var.
+ """
+
+ source: Matrix
+ layout: str
+ data_dtype: np.dtype
+ index_dtype: np.dtype
+ #: `indptr` is tracked apart from `indices` because the two can
+ #: legitimately differ: a narrow matrix with more than 2^31 nonzeros
+ #: needs int64 offsets over int32 column indices. Inferring one from
+ #: the other silently overflowed the offsets and corrupted the matrix.
+ pointer_dtype: np.dtype = np.dtype("int64")
+ report: Optional[CastReport] = None
+
+
+def plan_conversion(
+ obj: Any,
+ name: str,
+ *,
+ dtype: Optional[np.dtype] = None,
+ index_dtype: Optional[np.dtype] = None,
+ layout: Optional[str] = None,
+ chunk: int = DEFAULT_CHUNK,
+ force: bool = False,
+ console: Optional[Console] = None,
+) -> Plan:
+ """Decide what to do, and refuse here if it should not be done."""
+ src = describe(obj)
+ target_layout = resolve_layout(src, layout)
+ data_dtype = np.dtype(dtype) if dtype is not None else src.dtype
+
+ if index_dtype is not None:
+ idx_dtype = ptr_dtype = np.dtype(index_dtype)
+ elif src.sparse:
+ # Keep what the source used, each independently. Defaulting to
+ # int64 doubled the index arrays of every int32 store; inferring
+ # indptr from indices narrowed the offsets of every store that
+ # needed them wider.
+ idx_dtype = np.dtype(src.obj["indices"].dtype)
+ ptr_dtype = np.dtype(src.obj["indptr"].dtype)
+ else:
+ idx_dtype = ptr_dtype = np.dtype("int64")
+
+ report: Optional[CastReport] = None
+ if dtype is not None and data_dtype != src.dtype:
+ values = src.obj["data"] if src.sparse else src.obj
+ report = check_cast(values, data_dtype, chunk=chunk)
+ message = report.describe(src.dtype, data_dtype)
+ if not report.lossless and not force:
+ raise ValueError(f"{name}: {message}. Pass --force to convert anyway.")
+ if console is not None:
+ colour = "dim" if report.lossless else "yellow"
+ console.print(f"[{colour}]{name}: {message}[/]")
+
+ # Always, not only when asked: an inferred dtype can be too narrow too,
+ # and a silently overflowed offset is indistinguishable from corruption.
+ if src.sparse and not force:
+ check_index_dtype(src.obj, idx_dtype, ptr_dtype, src.shape)
+
+ if target_layout == "dense" and src.sparse:
+ projected = src.shape[0] * src.shape[1] * data_dtype.itemsize
+ check_growth(
+ _stored_bytes(src.obj), projected, force=force, what=f"{name} as dense"
+ )
+
+ return Plan(src, target_layout, data_dtype, idx_dtype, ptr_dtype, report)
+
+
+def convert_matrix(
+ obj: Any,
+ dst_parent: Any,
+ name: str,
+ *,
+ plan: Optional[Plan] = None,
+ dtype: Optional[np.dtype] = None,
+ index_dtype: Optional[np.dtype] = None,
+ layout: Optional[str] = None,
+ chunk: int = DEFAULT_CHUNK,
+ chunk_rows: int = 1024,
+ in_memory: bool = False,
+ force: bool = False,
+ console: Optional[Console] = None,
+) -> None:
+ """Write `obj` into `dst_parent` under `name`, converted.
+
+ Pass a `plan` from `plan_conversion` to have the checks already done;
+ without one they run here, which is convenient for a direct caller but
+ means the destination already exists by the time a refusal is raised.
+ """
+ if plan is None:
+ plan = plan_conversion(
+ obj, name, dtype=dtype, index_dtype=index_dtype, layout=layout,
+ chunk=chunk, force=force, console=console,
+ )
+ src = plan.source
+ target_layout = plan.layout
+ data_dtype = plan.data_dtype
+ idx_dtype = plan.index_dtype
+ ptr_dtype = plan.pointer_dtype
+
+ # --- then write -------------------------------------------------------
+ if target_layout == "dense":
+ if src.sparse:
+ densify(src, dst_parent, name, data_dtype=data_dtype, chunk_rows=chunk_rows)
+ else:
+ cast_dense(src, dst_parent, name, data_dtype=data_dtype, chunk_rows=chunk_rows)
+ return
+
+ if not src.sparse:
+ sparsify(
+ src,
+ dst_parent,
+ name,
+ enc=target_layout,
+ data_dtype=data_dtype,
+ index_dtype=idx_dtype,
+ pointer_dtype=ptr_dtype,
+ chunk_rows=chunk_rows,
+ console=console,
+ )
+ return
+
+ if target_layout == src.kind:
+ cast_sparse(
+ src,
+ dst_parent,
+ name,
+ data_dtype=data_dtype,
+ index_dtype=idx_dtype,
+ pointer_dtype=ptr_dtype,
+ chunk=chunk,
+ )
+ return
+
+ transpose = (
+ transpose_sparse_in_memory if in_memory else transpose_sparse_streaming
+ )
+ extra: Dict[str, Any] = (
+ {} if in_memory else {"chunk": chunk, "console": console}
+ )
+ transpose(
+ src,
+ dst_parent,
+ name,
+ data_dtype=data_dtype,
+ index_dtype=idx_dtype,
+ pointer_dtype=ptr_dtype,
+ **extra,
+ )
diff --git a/src/adata/core/select.py b/src/adata/core/select.py
index f1049bc..49edb43 100644
--- a/src/adata/core/select.py
+++ b/src/adata/core/select.py
@@ -100,11 +100,31 @@ def group_indices(
values = np.asarray(
col_chunk_as_strings(group, column, start, end, cache), dtype=str
)
- for label in dict.fromkeys(values.tolist()):
+
+ # One pass over the chunk, whatever the number of distinct labels.
+ # Taking `np.nonzero(values == label)` per label -- as an earlier
+ # version did -- rescanned the whole chunk once per label, so the
+ # cost was O(n_rows * n_groups): a million cells split by a thousand
+ # samples came to 10^9 comparisons, and `split` looked like a hang.
+ uniques, first_seen, codes = np.unique(
+ values, return_index=True, return_inverse=True
+ )
+ codes = codes.ravel()
+
+ # Sorting the codes puts each label's positions in one contiguous
+ # run, so every group is a slice rather than a search.
+ by_code = np.argsort(codes, kind="stable")
+ run_starts = np.searchsorted(codes[by_code], np.arange(len(uniques)), "left")
+ run_ends = np.searchsorted(codes[by_code], np.arange(len(uniques)), "right")
+
+ # `np.unique` sorts; `order` has to stay in order of first appearance,
+ # because it names the output files.
+ for code in np.argsort(first_seen, kind="stable"):
+ label = str(uniques[code])
if label not in buckets:
buckets[label] = []
order.append(label)
- buckets[label].append(np.nonzero(values == label)[0] + start)
+ buckets[label].append(by_code[run_starts[code] : run_ends[code]] + start)
return (
{k: np.concatenate(v).astype(np.int64) for k, v in buckets.items()},
diff --git a/src/adata/storage/__init__.py b/src/adata/storage/__init__.py
index cfea148..025bce5 100644
--- a/src/adata/storage/__init__.py
+++ b/src/adata/storage/__init__.py
@@ -450,30 +450,86 @@ def _is_string_src(src: Any) -> bool:
#: comfortably above a typical 1 MiB Lustre stripe while bounding peak memory.
TARGET_READ_BYTES = 32 * 1024 * 1024
-#: Assumed width of a variable-length string element, for read sizing only.
-#: h5py reports `itemsize` 8 for vlen strings because the value is a pointer,
-#: which would overestimate the row count by an order of magnitude and break
-#: the memory bound. Real cell and gene names sit well under this.
+#: Fallback width of a variable-length string element, used only when the
+#: real width cannot be sampled. h5py reports `itemsize` 8 for vlen strings
+#: because the value is a pointer, which would overestimate the row count by
+#: an order of magnitude. Real cell and gene names sit well under this.
VLEN_ELEMENT_BYTES = 64
+#: Elements read when sampling a variable-length array's real element width.
+#: One small read, against a copy that is about to stream the whole array.
+VLEN_SAMPLE_ROWS = 256
-def _row_bytes(shape: Sequence[int], dtype: Any) -> int:
- """In-memory size of one row along the first axis."""
+
+def _is_vlen(dtype: Any) -> bool:
+ """Does this dtype hide its real element width behind a pointer?
+
+ 'O' is how h5py spells vlen str, 'T' is numpy StringDType as reported by
+ zarr-python 3. Neither `itemsize` reflects the bytes actually stored.
+ """
+ itemsize = int(getattr(dtype, "itemsize", 0) or 0)
+ return itemsize <= 0 or getattr(dtype, "kind", None) in ("O", "T")
+
+
+def _sample_element_bytes(src: Any, n_rows: int) -> int:
+ """Mean stored width of a variable-length element, by reading a few.
+
+ An assumed width is not a bound. Estimating 64 bytes and reading
+ `TARGET_READ_BYTES // 64` elements means the read is 32 MiB only if the
+ guess holds: at 4 KiB elements it is 2 GiB, and for an array shorter than
+ the computed step the whole thing is read at once. Measured before this:
+ copying 200,000 strings of 4 KiB peaked at 827 MB against a stated 32 MiB
+ budget.
+
+ One small read fixes that, and it is negligible beside the copy it is
+ about to size -- `uns` can hold arbitrary text, so the width is not
+ something this layer can assume.
+ """
+ try:
+ sample = np.asarray(src[: min(VLEN_SAMPLE_ROWS, max(1, n_rows))])
+ except Exception: # pragma: no cover - unreadable source
+ return VLEN_ELEMENT_BYTES
+
+ total = 0
+ count = 0
+ for value in sample.reshape(-1)[:VLEN_SAMPLE_ROWS]:
+ try:
+ total += len(value)
+ except TypeError: # pragma: no cover - non-sized element
+ total += VLEN_ELEMENT_BYTES
+ count += 1
+
+ if not count:
+ return VLEN_ELEMENT_BYTES
+ # The floor keeps a column of empty strings from producing an unbounded
+ # step; the object header dominates at that size anyway.
+ return max(VLEN_ELEMENT_BYTES, total // count)
+
+
+def _row_bytes(
+ shape: Sequence[int], dtype: Any, element_bytes: int = VLEN_ELEMENT_BYTES
+) -> int:
+ """In-memory size of one row along the first axis.
+
+ `element_bytes` is the measured width for a variable-length dtype; the
+ default is the fallback for callers that have no sample to offer.
+ """
width = 1
for dim in shape[1:]:
width *= max(1, int(dim))
itemsize = int(getattr(dtype, "itemsize", 0) or 0)
- # 'O' is how h5py spells vlen str, 'T' is numpy StringDType as reported by
- # zarr-python 3. Neither itemsize reflects the bytes actually stored.
- if itemsize <= 0 or getattr(dtype, "kind", None) in ("O", "T"):
- itemsize = VLEN_ELEMENT_BYTES
+ if _is_vlen(dtype):
+ itemsize = element_bytes
return max(1, width * itemsize)
def _chunk_step(
- shape: Sequence[int], chunks: Optional[Sequence[int]], dtype: Any
+ shape: Sequence[int],
+ chunks: Optional[Sequence[int]],
+ dtype: Any,
+ element_bytes: int = VLEN_ELEMENT_BYTES,
) -> int:
"""Rows to copy per read, sized for the filesystem rather than the source.
@@ -492,7 +548,7 @@ def _chunk_step(
if chunks is not None and len(chunks) > 0 and chunks[0]:
chunk_rows = max(1, int(chunks[0]))
- step = max(1, TARGET_READ_BYTES // _row_bytes(shape, dtype))
+ step = max(1, TARGET_READ_BYTES // _row_bytes(shape, dtype, element_bytes))
if chunk_rows:
# Never go below a single chunk: a partial read still decompresses the
# whole thing, so a smaller step costs the same I/O for less data. That
@@ -532,7 +588,11 @@ def copy_dataset(src: Any, dst_group: Any, name: str) -> Any:
ds[()] = src[()]
return ds
- step = _chunk_step(shape, getattr(src, "chunks", None), src.dtype)
+ element_bytes = (
+ _sample_element_bytes(src, shape[0]) if _is_vlen(src.dtype)
+ else VLEN_ELEMENT_BYTES
+ )
+ step = _chunk_step(shape, getattr(src, "chunks", None), src.dtype, element_bytes)
for start in range(0, shape[0], step):
end = min(start + step, shape[0])
if len(shape) == 1:
diff --git a/tests/perf_counters.py b/tests/perf_counters.py
new file mode 100644
index 0000000..cd7e257
--- /dev/null
+++ b/tests/perf_counters.py
@@ -0,0 +1,529 @@
+"""Deterministic cost counters for the complexity guards.
+
+These measure *how much work* an operation does, never how long it takes. A
+test that can fail because a CI runner was busy does not belong in a merge
+gate, so nothing here touches the clock.
+
+Why patch the libraries rather than `adata`
+-------------------------------------------
+The hooks wrap h5py and zarr themselves. A seam inside `src/adata/` would only
+measure the call sites that remembered to use it, which is precisely the wrong
+property for a guard whose job is to catch the read you did not think of. The
+matrix paths in particular slice the backend objects directly -- see
+`core/subset.py` `_copy_dense_rows` and `core/concat.py` `_concat_csr` -- and
+no in-repo helper sees them at all.
+
+What is counted
+---------------
+Elements and bytes, not just calls. The `--merge` hang was n calls each reading
+n elements; counting calls alone would also miss a vectorised-but-quadratic
+variant that reads n elements n times in one call.
+
+Known bypasses
+--------------
+These reach the file without passing through any hook below:
+
+* ``h5py.Dataset.read_direct``
+* ``np.asarray(dataset)`` and ``dataset[...]`` via ``__array__`` (verified:
+ ``np.asarray`` adds nothing to the element count)
+* ``dataset.asstr()[...]``, which goes through ``AsStrWrapper.__getitem__``
+* ``dataset.fields(...)``
+
+None are used in `src/adata/` today. If you add one, the counters will silently
+report less work than actually happened -- which is why every guard also
+asserts a *lower* bound, and why `test_performance.py` keeps a canary test with
+a known absolute count.
+"""
+
+from __future__ import annotations
+
+import sys
+import tracemalloc
+from contextlib import contextmanager
+from dataclasses import dataclass, field
+from typing import Any, Callable, Dict, Iterator, List
+
+import numpy as np
+
+
+@dataclass
+class IOCounts:
+ """Live tally of backend reads and writes.
+
+ The object is yielded before the work runs and mutated in place, so read
+ the fields after the `with` block closes.
+ """
+
+ h5_calls: int = 0
+ h5_elements: int = 0
+ h5_write_calls: int = 0
+ h5_written_elements: int = 0
+ zarr_calls: int = 0
+ zarr_elements: int = 0
+ store_get: int = 0
+ store_get_bytes: int = 0
+ store_set: int = 0
+ store_delete: int = 0
+
+ #: Per-dataset element counts, for diagnosing which column blew up.
+ by_name: Dict[str, int] = field(default_factory=dict)
+
+ @property
+ def elements(self) -> int:
+ """Elements read through either backend's array interface."""
+ return self.h5_elements + self.zarr_elements
+
+ @property
+ def calls(self) -> int:
+ return self.h5_calls + self.zarr_calls
+
+ @property
+ def writes(self) -> int:
+ """Elements written through either backend.
+
+ Zarr is counted in store keys rather than elements -- a chunk write
+ is one `set` -- so the two are not the same unit. For a guard that
+ only compares a command against itself at two sizes, that is fine.
+ """
+ return self.h5_written_elements + self.store_set
+
+ @property
+ def work(self) -> int:
+ """Reads plus writes, for commands whose job is mostly writing.
+
+ `import` and `create` read almost nothing; measuring only reads made
+ their guards vacuous, which the lower bound in `assert_grows_linearly`
+ caught.
+ """
+ return self.reads + self.writes
+
+ @property
+ def reads(self) -> int:
+ """The headline number: array elements plus store-level fetches.
+
+ Covers both the "read the same column n times" shape and the
+ "re-fetch the same chunk n times" shape.
+ """
+ return self.elements + self.store_get
+
+ def __str__(self) -> str: # pragma: no cover - diagnostic only
+ return (
+ f"read={self.elements} (h5={self.h5_elements} "
+ f"zarr={self.zarr_elements}) wrote={self.h5_written_elements} "
+ f"calls={self.calls} store: get={self.store_get} "
+ f"set={self.store_set} delete={self.store_delete}"
+ )
+
+
+def _size(value: Any) -> int:
+ try:
+ return int(np.asarray(value).size)
+ except Exception: # pragma: no cover - exotic dtypes
+ return 1
+
+
+@contextmanager
+def count_io() -> Iterator[IOCounts]:
+ """Count backend reads and writes for the duration of the block.
+
+ Patches four seams, all verified against h5py 3.15.1 and zarr 3.1.5:
+
+ ====================================== =================================
+ ``h5py.Dataset.__getitem__`` HDF5 reads
+ ``h5py.Dataset.__setitem__`` HDF5 writes
+ ``h5py.Group.create_dataset`` HDF5 writes made at creation
+ ``zarr.Array.__getitem__`` Zarr reads, v2 and v3 alike
+ ``zarr.storage.LocalStore.get`` chunk and metadata fetches
+ ``LocalStore.set`` / ``.delete`` write storms
+ ====================================== =================================
+
+ Both write seams are needed: `create_dataset(name, data=...)` writes its
+ payload during creation and never touches `__setitem__`, so hooking only
+ the latter leaves `import` and `create` measuring zero.
+
+ The store hooks are the ones that see a metadata write storm: attribute
+ writes never touch `Array.__getitem__`, so an O(k^2) attribute rewrite is
+ invisible at the array level and obvious at the store level.
+
+ The `LocalStore` methods are coroutines and are wrapped as coroutines.
+ """
+ import h5py
+ import zarr
+ from zarr.storage import LocalStore
+
+ counts = IOCounts()
+
+ h5_get = h5py.Dataset.__getitem__
+ h5_set = h5py.Dataset.__setitem__
+ h5_create = h5py.Group.create_dataset
+ zarr_get = zarr.Array.__getitem__
+ store_get = LocalStore.get
+ store_set = LocalStore.set
+ store_delete = LocalStore.delete
+
+ def _record(name: str, n: int) -> None:
+ counts.by_name[name] = counts.by_name.get(name, 0) + n
+
+ def h5_wrapper(self: Any, key: Any) -> Any:
+ result = h5_get(self, key)
+ n = _size(result)
+ counts.h5_calls += 1
+ counts.h5_elements += n
+ _record(getattr(self, "name", "?"), n)
+ return result
+
+ def zarr_wrapper(self: Any, key: Any) -> Any:
+ result = zarr_get(self, key)
+ n = _size(result)
+ counts.zarr_calls += 1
+ counts.zarr_elements += n
+ _record(getattr(self, "path", "?") or "/", n)
+ return result
+
+ def h5_set_wrapper(self: Any, key: Any, value: Any) -> Any:
+ counts.h5_write_calls += 1
+ counts.h5_written_elements += _size(value)
+ return h5_set(self, key, value)
+
+ def h5_create_wrapper(self: Any, name: Any, *args: Any, **kwargs: Any) -> Any:
+ counts.h5_write_calls += 1
+ data = kwargs.get("data")
+ if data is None and args:
+ # positional signature is (name, shape, dtype, data, ...)
+ data = args[2] if len(args) > 2 else None
+ if data is not None:
+ counts.h5_written_elements += _size(data)
+ return h5_create(self, name, *args, **kwargs)
+
+ async def get_wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
+ result = await store_get(self, *args, **kwargs)
+ counts.store_get += 1
+ if result is not None:
+ try:
+ counts.store_get_bytes += len(result)
+ except TypeError: # pragma: no cover - prototype buffers
+ pass
+ return result
+
+ async def set_wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
+ counts.store_set += 1
+ return await store_set(self, *args, **kwargs)
+
+ async def delete_wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
+ counts.store_delete += 1
+ return await store_delete(self, *args, **kwargs)
+
+ h5py.Dataset.__getitem__ = h5_wrapper
+ h5py.Dataset.__setitem__ = h5_set_wrapper
+ h5py.Group.create_dataset = h5_create_wrapper
+ zarr.Array.__getitem__ = zarr_wrapper
+ LocalStore.get = get_wrapper
+ LocalStore.set = set_wrapper
+ LocalStore.delete = delete_wrapper
+ try:
+ yield counts
+ finally:
+ h5py.Dataset.__getitem__ = h5_get
+ h5py.Dataset.__setitem__ = h5_set
+ h5py.Group.create_dataset = h5_create
+ zarr.Array.__getitem__ = zarr_get
+ LocalStore.get = store_get
+ LocalStore.set = store_set
+ LocalStore.delete = store_delete
+
+
+@contextmanager
+def count_allocations() -> Iterator[List[int]]:
+ """Peak Python allocation during the block, in bytes, as `[peak]`.
+
+ `tracemalloc` sees Python-level allocation, which includes the numpy array
+ *objects* and every Python list and string built along the way, but not
+ memory a C extension takes outside the allocator. That is the right scope
+ here: the defects this catches are whole-column Python lists, and h5py's
+ own buffers are not what we are policing.
+
+ Deterministic for deterministic input, which is why it can gate a merge.
+ """
+ peak = [0]
+ started = not tracemalloc.is_tracing()
+ if started:
+ tracemalloc.start()
+ else: # pragma: no cover - nested use
+ tracemalloc.reset_peak()
+ try:
+ yield peak
+ finally:
+ peak[0] = tracemalloc.get_traced_memory()[1]
+ if started:
+ tracemalloc.stop()
+
+
+@contextmanager
+def count_lines(prefix: str) -> Iterator[List[int]]:
+ """Count executed Python line events in files under `prefix`, as `[n]`.
+
+ A deterministic stand-in for CPU time. It is what catches a quadratic that
+ performs no extra I/O and allocates nothing extra -- `if x not in a_list`
+ inside a loop, say. Costs a 10-50x slowdown, so keep it to the few paths
+ with known Python-level inner loops and to small inputs.
+
+ Blind to work done inside numpy's C code, so it complements the allocation
+ and I/O counters rather than replacing them.
+ """
+ total = [0]
+
+ def local_trace(frame: Any, event: str, arg: Any) -> Any:
+ if event == "line":
+ total[0] += 1
+ return local_trace
+
+ def trace(frame: Any, event: str, arg: Any) -> Any:
+ if event == "call" and frame.f_code.co_filename.startswith(prefix):
+ return local_trace
+ return None
+
+ previous = sys.gettrace()
+ sys.settrace(trace)
+ try:
+ yield total
+ finally:
+ sys.settrace(previous)
+
+
+#: numpy entry points that scan their operand, and the argument that is
+#: scanned. Patched by name, so only calls written as `np.f(...)` are seen.
+_SCANNING = {
+ "nonzero": 0,
+ "flatnonzero": 0,
+ "unique": 0,
+ "argsort": 0,
+ "sort": 0,
+ "searchsorted": 0,
+ "bincount": 0,
+ "equal": 0,
+ "isin": 0,
+}
+
+
+@contextmanager
+def count_scanned_elements() -> Iterator[List[int]]:
+ """Elements handed to numpy's scanning primitives, as `[n]`.
+
+ The third instrument, and the one the other two cannot replace. A loop of
+ the shape::
+
+ for label in distinct_labels:
+ np.nonzero(values == label)
+
+ reads nothing extra (the chunk is already in memory), allocates nothing
+ that lasts, and executes a constant number of Python lines per label. It
+ is invisible to `count_io`, to `count_allocations` and to `count_lines`
+ alike, and it is O(len(values) * len(distinct_labels)).
+
+ Counting what is passed into numpy makes it visible, in the same spirit
+ as the `_CountingStr` guard in `test_performance.py`: when the cost lives
+ inside C, count what is handed to C.
+
+ What this does and does not see
+ -------------------------------
+ Only calls that go through the `numpy` module namespace by name. An
+ operator -- `values == label` -- dispatches to `ndarray.__eq__` and then
+ to the ufunc in C, so it never passes the patched `np.equal`; a scan
+ written purely as `(a == b).sum()` is invisible. Method form,
+ `arr.argsort()`, is invisible for the same reason.
+
+ So this is a floor on the real work, not a measurement of it, which is
+ exactly what a guard needs: it can only under-report, and the guards
+ using it assert a lower bound so that under-reporting to nothing fails
+ rather than passes.
+ """
+ total = [0]
+ originals = {name: getattr(np, name) for name in _SCANNING}
+
+ def wrap(name: str, position: int):
+ original = originals[name]
+
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
+ if len(args) > position:
+ try:
+ total[0] += int(np.size(args[position]))
+ except Exception: # pragma: no cover - exotic operands
+ pass
+ return original(*args, **kwargs)
+
+ return wrapper
+
+ for name, position in _SCANNING.items():
+ setattr(np, name, wrap(name, position))
+ try:
+ yield total
+ finally:
+ for name, original in originals.items():
+ setattr(np, name, original)
+
+
+# ---------------------------------------------------------------------------
+# the growth assertion
+
+
+#: Ratio of successive increments that counts as super-linear.
+#:
+#: Measured at 4x spacing, the increment ratio is 4.0 for linear work, about
+#: 4.4 for n log n, 8 for n**1.5 and 16 for quadratic. 6 sits in the gap with
+#: room on both sides, so it needs no per-test tuning.
+GROWTH_LIMIT = 6.0
+
+#: Sizes every growth guard runs at. 4x spacing is what makes GROWTH_LIMIT
+#: mean what it says; changing one without the other invalidates the constant.
+SIZES = (64, 256, 1024)
+
+
+def assert_grows_linearly(
+ measure: Callable[[int], int],
+ *,
+ what: str,
+ axis: str,
+ sizes: tuple = SIZES,
+ limit: float = GROWTH_LIMIT,
+) -> None:
+ """Assert `measure` grows no faster than linearly in one axis.
+
+ Compares successive *increments* rather than raw counts::
+
+ d1 = c(4n) - c(n)
+ d2 = c(16n) - c(4n)
+ assert d2 <= limit * d1
+
+ The increment form is what removes the arbitrary additive constant. Any
+ fixed setup cost -- opening the store, writing the skeleton, reading the
+ index once -- appears in both differences and cancels exactly, so there is
+ no slack parameter to pick and no floor for a small-coefficient quadratic
+ to hide under.
+
+ `d1 >= sizes[1]` is a vacuity guard. If a future refactor moves a read onto
+ a path the counters do not see (see the module docstring), every ratio
+ would pass trivially; this fails loudly instead.
+
+ Scale exactly one axis per call and hold the others fixed. Scaling two at
+ once makes legitimate work look quadratic -- an outer-join concat really
+ does produce n_obs x n_var_union cells.
+ """
+ small, mid, large = sizes
+ c_small, c_mid, c_large = (measure(n) for n in (small, mid, large))
+
+ d1 = c_mid - c_small
+ d2 = c_large - c_mid
+ series = (
+ f"{what}, scaling {axis}: "
+ f"{small}->{c_small}, {mid}->{c_mid}, {large}->{c_large} "
+ f"(increments {d1} then {d2}"
+ + (f", ratio {d2 / d1:.1f}" if d1 > 0 else "")
+ + ")"
+ )
+
+ # Half an operation per element added. Work that is exactly one
+ # operation per element -- `export dict` reads each key once -- gives
+ # d1 = mid - small, which is 0.75 * mid at 4x spacing, so a bound of
+ # `mid` would reject correct code. Anything the counters cannot see at
+ # all gives 0 and still fails.
+ floor = (mid - small) / 2
+ assert d1 >= floor, (
+ f"Too little measured work for the growth ratio to mean anything -- "
+ f"the counters are probably not seeing this operation. Expected at "
+ f"least {floor:.0f} more operations between {small} and {mid}. "
+ f"{series}"
+ )
+ assert d2 <= limit * d1, (
+ f"Cost grows faster than linearly in {axis}. Expect an increment "
+ f"ratio near 4.0 for linear work; {limit} is the limit and quadratic "
+ f"would be about 16. {series}"
+ )
+
+
+#: How much a "flat" cost may drift across the whole size span.
+#:
+#: Not 1.0: opening a store, resolving an index and printing a tree all cost
+#: a little more when there is more to describe -- a longer index name, a
+#: wider shape to format. 1.5 across a 64x span leaves room for that and none
+#: at all for reading the data, which would be 64x.
+FLAT_TOLERANCE = 1.5
+
+#: Span for the flat guards. Wider than SIZES, because the claim is stronger
+#: and a wide span is what makes it convincing.
+FLAT_SIZES = (64, 4096)
+
+
+def assert_independent_of(
+ measure: Callable[[int], int],
+ *,
+ what: str,
+ axis: str,
+ sizes: tuple = FLAT_SIZES,
+ tolerance: float = FLAT_TOLERANCE,
+) -> None:
+ """Assert `measure` does not grow with `axis` at all.
+
+ A stronger claim than `assert_grows_linearly`, and the right one wherever
+ the tool promises work proportional to something other than input size:
+
+ * **Inspection.** `view` and `ls` read shapes, dtypes and attributes and
+ never the values behind them, which is the whole reason they return
+ instantly on a store too large to open. Linear growth here would mean
+ the promise had quietly stopped holding.
+ * **Grouping.** Work per row must not depend on how many groups there are.
+ * **Streaming.** At a fixed chunk size, peak memory must not track the
+ size of the input.
+
+ Each of those reads as obvious prose and none of them is checked by a
+ linear-growth guard, which would happily accept a 64x increase.
+ """
+ small, large = sizes[0], sizes[-1]
+ c_small, c_large = measure(small), measure(large)
+
+ span = large / small
+ series = (
+ f"{what}, scaling {axis}: {small}->{c_small}, {large}->{c_large} "
+ f"over a {span:.0f}x span"
+ )
+ ratio = (c_large / c_small) if c_small else float("inf") if c_large else 1.0
+
+ assert ratio <= tolerance, (
+ f"Cost tracks {axis}, and it is supposed to be independent of it. "
+ f"Growing in step with the input would be about {span:.0f}x; "
+ f"{tolerance}x is the limit. {series} (ratio {ratio:.2f})"
+ )
+
+
+def assert_grows_slower_than_input(
+ measure: Callable[[int], int],
+ *,
+ what: str,
+ axis: str,
+ sizes: tuple,
+ at_least: float,
+) -> None:
+ """Assert cost grows at least `at_least` times slower than the input.
+
+ The middle ground between `assert_grows_linearly` and
+ `assert_independent_of`, and the honest shape of most of this tool's
+ streaming: peak memory is not flat -- an index or an indptr is read whole
+ -- but it is far below the input curve, and that margin is the feature.
+
+ Stating the margin as a factor rather than an absolute ceiling keeps the
+ guard meaningful across platforms and interpreters, and makes a drift
+ back towards linear fail long before it becomes a bug report.
+ """
+ small, large = sizes[0], sizes[-1]
+ c_small, c_large = measure(small), measure(large)
+
+ span = large / small
+ growth = (c_large / c_small) if c_small else float("inf")
+ budget = span / at_least
+
+ assert growth <= budget, (
+ f"{what} cost is tracking {axis} too closely. Input grew {span:.0f}x "
+ f"and cost grew {growth:.1f}x; the requirement is at least "
+ f"{at_least:.0f}x better than the input, i.e. no more than "
+ f"{budget:.0f}x. Series: {small}->{c_small}, {large}->{c_large}"
+ )
+
diff --git a/tests/test_benchmark_harness.py b/tests/test_benchmark_harness.py
new file mode 100644
index 0000000..7bc384b
--- /dev/null
+++ b/tests/test_benchmark_harness.py
@@ -0,0 +1,182 @@
+"""The benchmark harness has to be trustworthy before its numbers are.
+
+Only the measurement is tested here, not the cases. If peak RSS were
+attributed to the wrong process every table the project publishes would be
+wrong, and the failure would be invisible -- the numbers would still look
+plausible. Everything else in `benchmarks/` is reporting, and a mistake there
+shows up the moment anyone reads the page.
+"""
+
+from __future__ import annotations
+
+import sys
+
+import pytest
+
+from benchmarks._measure import Measurement, _looks_like_oom, measure
+
+
+#: A child that allocates nothing should cost about what a bare interpreter
+#: costs. Generous, because that depends on the build; far below the ballast
+#: below, which is the point.
+FLOOR_CEILING = 60 * 1024**2
+
+#: Allocated by the parent before measuring, to make an inherited floor
+#: impossible to miss.
+BALLAST = 300 * 1024**2
+
+
+def test_peak_rss_is_the_child_s_own_and_carries_no_floor():
+ """Two ways this number can lie, both of which it has.
+
+ `RUSAGE_CHILDREN` is a running maximum over every child a process has
+ reaped, so a 400 MB case makes every later one look like 400 MB. `wait4`
+ fixes that.
+
+ Worse, and what CI caught: on Linux a forked child inherits its parent's
+ resident pages, and `execve` folds that into the `maxrss` `wait4` reports.
+ A child of a fat parent could not appear small however little it used.
+ Measured under python:3.12-slim before the fix -- parent at 329.6 MB, a
+ no-op child reported 326.4 MB. `posix_spawn` reported 329.5 MB, so
+ dropping `preexec_fn` would not have helped; measuring from a shim
+ reported 8.1 MB.
+
+ macOS resets the high-water mark at exec and shows none of this, so this
+ is mostly a Linux guard -- which is the platform the benchmark runs on.
+
+ The test holds ballast in the parent for its whole duration so that an
+ inherited floor cannot hide.
+ """
+ ballast = bytearray(BALLAST)
+ ballast[::4096] = b"\x01" * len(ballast[::4096]) # make it resident
+
+ baseline = measure([sys.executable, "-c", "pass"])
+ big = measure([sys.executable, "-c", "x = bytearray(400 * 1024 * 1024)"])
+ small = measure([sys.executable, "-c", "x = bytearray(1024)"])
+
+ assert {baseline.status, big.status, small.status} == {"ok"}
+
+ # No floor: a child that allocates nothing looks like nothing, even though
+ # this process is holding 300 MB.
+ assert baseline.maxrss_bytes < FLOOR_CEILING, (
+ f"a no-op child reported {baseline.maxrss_bytes / 1e6:.0f} MB while "
+ f"this process held {BALLAST / 1e6:.0f} MB of ballast. The measured "
+ "peak is inheriting the parent's resident pages, so every benchmark "
+ "row would be floored at roughly the runner's own footprint."
+ )
+
+ # Isolation: the small child resembles the baseline, not the big one that
+ # ran before it.
+ assert big.maxrss_bytes > 300 * 1024**2, big
+ assert small.maxrss_bytes < baseline.maxrss_bytes * 2, (
+ f"peak RSS leaked between children: {small.maxrss_bytes / 1e6:.0f} MB "
+ f"for a 1 KB allocation, against a {baseline.maxrss_bytes / 1e6:.0f} MB "
+ f"baseline, after a {big.maxrss_bytes / 1e6:.0f} MB child"
+ )
+
+ del ballast
+
+
+def test_a_hang_is_reported_as_a_timeout_with_its_output_size(tmp_path):
+ """How the 0.5.1 hang presented: still running, output never growing.
+
+ A harness that recorded this as a slow success would have made the
+ reported bug look like ordinary slowness.
+ """
+ output = tmp_path / "partial.bin"
+ command = [
+ sys.executable,
+ "-c",
+ f"open({str(output)!r}, 'wb').write(b'x' * 1024); import time; time.sleep(60)",
+ ]
+ result = measure(command, output=output, timeout_s=2.0)
+
+ assert result.status == "timeout"
+ assert result.wall_s >= 2.0
+ assert result.output_bytes == 1024, "output size at the kill must be recorded"
+
+
+def test_a_failure_against_the_memory_ceiling_is_recorded_as_oom():
+ """A baseline that cannot cope is a result, not a crashed job.
+
+ Skipped on macOS, which refuses RLIMIT_AS; the ceiling only has to hold
+ on the Linux runner where the benchmark actually runs.
+ """
+ if not sys.platform.startswith("linux"):
+ pytest.skip("RLIMIT_AS is not enforceable on this platform")
+
+ result = measure(
+ [sys.executable, "-c", "x = bytearray(8 * 1024**3)"],
+ memory_limit=512 * 1024**2,
+ )
+ assert result.status == "oom", result
+
+
+@pytest.mark.parametrize(
+ "text",
+ ["MemoryError", "numpy: Unable to allocate 4.0 GiB", "std::bad_alloc"],
+)
+def test_out_of_memory_is_recognised_however_it_is_phrased(text):
+ assert _looks_like_oom(text)
+
+
+def test_an_ordinary_failure_is_not_mistaken_for_an_oom():
+ result = measure([sys.executable, "-c", "raise ValueError('nope')"])
+ assert result.status == "failed"
+ assert "ValueError" in result.stderr_tail
+
+
+def test_the_harness_refuses_to_overwrite_an_existing_output(tmp_path):
+ """A stale output would be sized and reported as this run's work.
+
+ Checked in the parent, not the shim, so it surfaces as an exception the
+ caller must fix rather than as a quiet "failed" row.
+ """
+ existing = tmp_path / "already.h5ad"
+ existing.write_bytes(b"old")
+ with pytest.raises(FileExistsError):
+ measure([sys.executable, "-c", "pass"], output=existing)
+
+
+def test_a_missing_binary_is_a_result_not_a_crash():
+ """`h5ls` ships with the HDF5 tools and is often simply absent."""
+ result = measure(["/nonexistent-binary-for-tests"])
+ assert result.status == "n/a"
+ assert "not installed" in result.stderr_tail
+
+
+def test_maxrss_is_normalised_to_bytes():
+ """`ru_maxrss` is KiB on Linux and bytes on macOS; the field is bytes.
+
+ A missing conversion would be a silent 1024x error in one direction on
+ one platform, which is exactly the kind of thing nobody notices in a
+ table of plausible-looking numbers.
+ """
+ result = measure([sys.executable, "-c", "pass"])
+ assert isinstance(result, Measurement)
+ # Any CPython start-up is well over 1 MB and well under 4 GB.
+ assert 1024**2 < result.maxrss_bytes < 4 * 1024**3, result.maxrss_bytes
+
+
+def test_publishing_keeps_the_page_prose():
+ """A republish must not reduce the docs page to bare tables.
+
+ `publish` rewrites `docs/BENCHMARKS.md` in full on every tag. An earlier
+ version wrote only the rendered results, which would have thrown away
+ everything explaining what the numbers mean the first time it ran.
+ """
+ from benchmarks.report import PAGE_TEMPLATE, build_page
+
+ template = PAGE_TEMPLATE.read_text()
+ assert "" in template, (
+ "the results marker is gone from page_template.md, so results would "
+ "be appended rather than placed"
+ )
+
+ page = build_page("## Results\n\nsome tables\n")
+ assert "some tables" in page
+ assert "Peak RSS is the headline" in page, "the framing was dropped"
+ assert "Running it yourself" in page, "the trailing sections were dropped"
+ assert page.count("\n# ") + page.startswith("# ") == 1, (
+ "the published page must have exactly one H1"
+ )
diff --git a/tests/test_commands_phase2.py b/tests/test_commands_phase2.py
index cf7bd8b..3ea6b99 100644
--- a/tests/test_commands_phase2.py
+++ b/tests/test_commands_phase2.py
@@ -758,3 +758,179 @@ def test_split_rejects_a_bad_zarr_format(tmp_path, sample):
)
assert result.exit_code == 1
assert "must be 2 or 3" in _out(result)
+
+
+# ---------------------------------------------------------------------------
+# concat --merge cost
+#
+# These assert how often a var column is read, not how long the merge takes.
+# The hang reported against 0.5.1 was invisible to every correctness test in
+# this file because all of them use two or three variables, and the defect was
+# quadratic in the number of variables: `tuple(read_str_all(col)[i] for i in
+# where)` re-evaluated `read_str_all` once per target variable. A wall-clock
+# assertion would be flaky on shared CI; a read count is exact.
+
+
+def _count_var_column_reads(monkeypatch):
+ """Count full-column reads performed while merging var."""
+ from adata.core import concat as concat_mod
+
+ calls: list = []
+ original = concat_mod.read_str_all
+
+ def counting(obj, *args, **kwargs):
+ calls.append(getattr(obj, "name", "?"))
+ return original(obj, *args, **kwargs)
+
+ monkeypatch.setattr(concat_mod, "read_str_all", counting)
+ return calls
+
+
+def _two_stores_with_var_columns(tmp_path, n_var: int, n_col: int) -> list:
+ genes = [f"g{i}" for i in range(n_var)]
+ var = pd.DataFrame(
+ {f"col{c}": [f"v{c}-{i}" for i in range(n_var)] for c in range(n_col)},
+ index=genes,
+ )
+ paths = []
+ for name, cells in (("a", ["c1", "c2"]), ("b", ["c3", "c4"])):
+ path = tmp_path / f"{name}.h5ad"
+ ad.AnnData(
+ X=np.ones((2, n_var), dtype="float32"),
+ obs=pd.DataFrame(index=cells),
+ var=var.copy(),
+ ).write_h5ad(path)
+ paths.append(path)
+ return paths
+
+
+@pytest.mark.parametrize("n_var", [4, 64])
+def test_concat_merge_same_reads_each_var_column_once_per_input(
+ tmp_path, monkeypatch, n_var
+):
+ """`--merge same` must not scale with the number of variables.
+
+ Two inputs and three var columns is six reads whatever n_var is. The
+ parametrisation is the whole point: an implementation whose cost grows
+ with n_var fails the second case while passing the first.
+ """
+ a, b = _two_stores_with_var_columns(tmp_path, n_var=n_var, n_col=3)
+ calls = _count_var_column_reads(monkeypatch)
+
+ out = tmp_path / "m.h5ad"
+ result = runner.invoke(
+ app, ["concat", str(a), str(b), "-o", str(out), "--merge", "same"]
+ )
+ assert result.exit_code == 0, _out(result)
+ assert len(calls) == 6, f"{len(calls)} reads for {n_var} vars: {calls[:10]}"
+
+ var = ad.read_h5ad(out).var
+ assert list(var.columns) == ["col0", "col1", "col2"]
+ assert var["col0"].tolist() == [f"v0-{i}" for i in range(n_var)]
+
+
+def test_concat_merge_first_does_not_read_var_column_values(
+ tmp_path, monkeypatch
+):
+ """`first` decides on presence alone, so it reads no column values."""
+ a, b = _two_stores_with_var_columns(tmp_path, n_var=64, n_col=3)
+ calls = _count_var_column_reads(monkeypatch)
+
+ out = tmp_path / "m.h5ad"
+ result = runner.invoke(
+ app, ["concat", str(a), str(b), "-o", str(out), "--merge", "first"]
+ )
+ assert result.exit_code == 0, _out(result)
+ assert calls == [], f"'first' read {len(calls)} columns it did not compare"
+ assert list(ad.read_h5ad(out).var.columns) == ["col0", "col1", "col2"]
+
+
+def test_concat_merge_drop_is_accepted_and_keeps_no_var_columns(tmp_path):
+ """`drop` is the documented default, so it has to be sayable."""
+ a, b = _two_stores_with_var_columns(tmp_path, n_var=4, n_col=2)
+ out = tmp_path / "m.h5ad"
+ result = runner.invoke(
+ app,
+ ["concat", str(a), str(b), "-o", str(out),
+ "--merge", "drop", "--uns-merge", "drop"],
+ )
+ assert result.exit_code == 0, _out(result)
+
+ got = ad.read_h5ad(out)
+ assert list(got.var.columns) == []
+ assert dict(got.uns) == {}
+
+
+# ---------------------------------------------------------------------------
+# concat refuses mismatched matrix encodings
+#
+# The check has been there since concat was written and nothing exercised it.
+# It is the error a user is most likely to meet with real per-sample files,
+# since whether a matrix lands as CSR or CSC depends on how it was made.
+
+
+def _store_with_layout(path, layout, *, cells, layer=None):
+ matrix = sparse.csr_matrix(np.ones((len(cells), 3), dtype="float32"))
+ if layout == "csc":
+ matrix = matrix.tocsc()
+ elif layout == "dense":
+ matrix = matrix.toarray()
+ obj = ad.AnnData(
+ X=matrix,
+ obs=pd.DataFrame(index=cells),
+ var=pd.DataFrame(index=["g1", "g2", "g3"]),
+ )
+ if layer is not None:
+ obj.layers["counts"] = (
+ sparse.csr_matrix(np.ones((len(cells), 3), dtype="float32"))
+ if layer == "csr"
+ else np.ones((len(cells), 3), dtype="float32")
+ )
+ obj.write_h5ad(path)
+ return path
+
+
+@pytest.mark.parametrize(
+ "left,right", [("csr", "csc"), ("csr", "dense"), ("csc", "dense")]
+)
+def test_concat_refuses_mismatched_x_encodings(tmp_path, left, right):
+ a = _store_with_layout(tmp_path / "a.h5ad", left, cells=["c1", "c2"])
+ b = _store_with_layout(tmp_path / "b.h5ad", right, cells=["c3", "c4"])
+ out = tmp_path / "m.h5ad"
+
+ result = runner.invoke(app, ["concat", str(a), str(b), "-o", str(out)])
+ assert result.exit_code == 1
+ text = _out(result)
+ assert "Cannot concatenate 'X'" in text
+ # The error has to name the way out, which until `convert` existed it
+ # could not do.
+ assert "adata convert" in text and "--layout" in text
+ assert not out.exists(), "nothing may be written when the check fails"
+
+
+def test_concat_refuses_mismatched_layer_encodings(tmp_path):
+ a = _store_with_layout(tmp_path / "a.h5ad", "csr", cells=["c1", "c2"],
+ layer="csr")
+ b = _store_with_layout(tmp_path / "b.h5ad", "csr", cells=["c3", "c4"],
+ layer="dense")
+ out = tmp_path / "m.h5ad"
+
+ result = runner.invoke(app, ["concat", str(a), str(b), "-o", str(out)])
+ assert result.exit_code == 1
+ assert "layers/counts" in _out(result)
+
+
+def test_concat_succeeds_once_the_encodings_are_converted(tmp_path):
+ """The suggested fix has to actually work, so the test follows it."""
+ a = _store_with_layout(tmp_path / "a.h5ad", "csr", cells=["c1", "c2"])
+ b = _store_with_layout(tmp_path / "b.h5ad", "csc", cells=["c3", "c4"])
+
+ converted = tmp_path / "b-csr.h5ad"
+ assert runner.invoke(
+ app, ["convert", str(b), "X", "-o", str(converted), "--layout", "csr"]
+ ).exit_code == 0
+
+ out = tmp_path / "m.h5ad"
+ result = runner.invoke(app, ["concat", str(a), str(converted), "-o", str(out)])
+ assert result.exit_code == 0, _out(result)
+ assert ad.read_h5ad(out).shape == (4, 3)
diff --git a/tests/test_convert.py b/tests/test_convert.py
new file mode 100644
index 0000000..b53e0d6
--- /dev/null
+++ b/tests/test_convert.py
@@ -0,0 +1,693 @@
+"""Tests for `adata convert`.
+
+scipy is the oracle throughout. A transpose or a cast is only correct if it
+agrees exactly with what scipy would have produced from the same matrix, and
+"looks plausible" is not a standard worth having for something that rewrites
+people's data.
+"""
+
+from __future__ import annotations
+
+import re
+from pathlib import Path
+
+import numpy as np
+import pytest
+from typer.testing import CliRunner
+
+from adata.cli import app
+
+ad = pytest.importorskip("anndata", reason="anndata is required for these tests")
+pd = pytest.importorskip("pandas")
+sparse = pytest.importorskip("scipy.sparse")
+
+runner = CliRunner()
+_ANSI = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]")
+
+
+def _out(result) -> str:
+ text = result.stdout + (result.stderr or "")
+ return " ".join(_ANSI.sub("", text).split())
+
+
+def _matrix(n_obs=40, n_var=25, density=0.2, layout="csr", seed=0, integral=True):
+ rng = np.random.default_rng(seed)
+ matrix = sparse.random(
+ n_obs, n_var, density=density, format="csr", dtype="float64",
+ random_state=rng,
+ )
+ if integral:
+ # Counts, which is what issue #13 is about: float32 holds them exactly.
+ matrix.data = np.round(matrix.data * 100)
+ return matrix.tocsc() if layout == "csc" else matrix
+
+
+def _store(path: Path, matrix, *, layers=None, raw=False) -> Path:
+ obj = ad.AnnData(
+ X=matrix,
+ obs=pd.DataFrame(index=[f"c{i}" for i in range(matrix.shape[0])]),
+ var=pd.DataFrame(index=[f"g{i}" for i in range(matrix.shape[1])]),
+ )
+ for name, value in (layers or {}).items():
+ obj.layers[name] = value
+ if raw:
+ obj.raw = obj
+ obj.write_h5ad(path)
+ return path
+
+
+def _dense(matrix):
+ return matrix.toarray() if sparse.issparse(matrix) else np.asarray(matrix)
+
+
+# ---------------------------------------------------------------------------
+# layout
+
+
+@pytest.mark.parametrize("in_memory", [False, True], ids=["streaming", "in-memory"])
+@pytest.mark.parametrize("source,target", [("csr", "csc"), ("csc", "csr")])
+def test_transpose_matches_scipy_exactly(tmp_path, source, target, in_memory):
+ """Both paths, both directions, against scipy's own conversion.
+
+ Having two implementations is only worth it if they agree; that is the
+ whole reason the streaming one is not checked merely against itself.
+ """
+ matrix = _matrix(layout=source)
+ store = _store(tmp_path / "in.h5ad", matrix)
+
+ out = tmp_path / f"{source}-{target}-{in_memory}.h5ad"
+ argv = ["convert", str(store), "X", "-o", str(out), "--layout", target]
+ if in_memory:
+ argv.append("--in-memory")
+ result = runner.invoke(app, argv)
+ assert result.exit_code == 0, _out(result)
+
+ got = ad.read_h5ad(out).X
+ expected = matrix.tocsc() if target == "csc" else matrix.tocsr()
+ assert got.format == target
+ assert np.array_equal(got.indptr, expected.indptr)
+ assert np.array_equal(got.indices, expected.indices)
+ assert np.array_equal(got.data, expected.data)
+
+
+def test_transposing_twice_is_the_identity(tmp_path):
+ matrix = _matrix()
+ store = _store(tmp_path / "in.h5ad", matrix)
+
+ mid, back = tmp_path / "mid.h5ad", tmp_path / "back.h5ad"
+ assert runner.invoke(
+ app, ["convert", str(store), "X", "-o", str(mid), "--layout", "csc"]
+ ).exit_code == 0
+ assert runner.invoke(
+ app, ["convert", str(mid), "X", "-o", str(back), "--layout", "csr"]
+ ).exit_code == 0
+
+ result = ad.read_h5ad(back).X
+ assert result.format == "csr"
+ assert np.array_equal(result.indptr, matrix.indptr)
+ assert np.array_equal(result.indices, matrix.indices)
+ assert np.array_equal(result.data, matrix.data)
+
+
+def test_the_two_transpose_paths_agree_on_an_awkward_matrix(tmp_path):
+ """Empty rows, a full row, and a single-entry column all in one."""
+ dense = np.zeros((6, 5))
+ dense[0, :] = [1, 2, 3, 4, 5] # full row
+ dense[3, 2] = 7 # lone entry
+ # rows 1, 2, 4, 5 stay empty
+ matrix = sparse.csr_matrix(dense)
+ store = _store(tmp_path / "in.h5ad", matrix)
+
+ outputs = {}
+ for tag, extra in (("stream", []), ("memory", ["--in-memory"])):
+ out = tmp_path / f"{tag}.h5ad"
+ assert runner.invoke(
+ app,
+ ["convert", str(store), "X", "-o", str(out), "--layout", "csc", *extra],
+ ).exit_code == 0
+ outputs[tag] = ad.read_h5ad(out).X
+
+ expected = matrix.tocsc()
+ for tag, got in outputs.items():
+ assert np.array_equal(got.indptr, expected.indptr), tag
+ assert np.array_equal(got.indices, expected.indices), tag
+ assert np.array_equal(got.data, expected.data), tag
+
+
+# ---------------------------------------------------------------------------
+# dtype -- what issue #13 asked for
+
+
+def test_counts_stored_as_float64_convert_to_float32_and_shrink(tmp_path):
+ """The reported case, end to end, including that the file gets smaller.
+
+ An early version produced an output eight times the input, because it
+ created the arrays with a fixed 65,536-element chunk and promoted int32
+ indices to int64. A conversion asked for to halve a file must not
+ enlarge it, so the size is part of the test.
+ """
+ import h5py
+
+ matrix = _matrix(n_obs=200, n_var=120, density=0.1)
+ store = _store(tmp_path / "in.h5ad", matrix)
+ out = tmp_path / "f32.h5ad"
+
+ result = runner.invoke(
+ app, ["convert", str(store), "X", "-o", str(out), "--dtype", "float32"]
+ )
+ assert result.exit_code == 0, _out(result)
+
+ got = ad.read_h5ad(out).X
+ assert got.dtype == np.dtype("float32")
+ assert np.array_equal(got.toarray(), matrix.toarray().astype("float32"))
+
+ def stored(path):
+ with h5py.File(path) as handle:
+ return sum(
+ handle["X"][key].id.get_storage_size()
+ for key in ("data", "indices", "indptr")
+ )
+
+ assert stored(out) < stored(store), (
+ f"X grew from {stored(store):,} to {stored(out):,} bytes on a "
+ "float64 -> float32 conversion"
+ )
+
+
+def test_the_index_dtype_is_preserved_unless_asked(tmp_path):
+ """Defaulting to int64 silently doubled every int32 store's indices."""
+ import h5py
+
+ store = _store(tmp_path / "in.h5ad", _matrix())
+ with h5py.File(store) as handle:
+ source_dtype = handle["X/indices"].dtype
+
+ out = tmp_path / "out.h5ad"
+ assert runner.invoke(
+ app, ["convert", str(store), "X", "-o", str(out), "--dtype", "float32"]
+ ).exit_code == 0
+
+ with h5py.File(out) as handle:
+ assert handle["X/indices"].dtype == source_dtype
+
+
+def test_indices_can_be_narrowed_when_asked(tmp_path):
+ import h5py
+
+ store = _store(tmp_path / "in.h5ad", _matrix())
+ out = tmp_path / "out.h5ad"
+ result = runner.invoke(
+ app,
+ ["convert", str(store), "X", "-o", str(out), "--indices-dtype", "int32"],
+ )
+ assert result.exit_code == 0, _out(result)
+
+ with h5py.File(out) as handle:
+ assert handle["X/indices"].dtype == np.dtype("int32")
+ assert handle["X/indptr"].dtype == np.dtype("int32")
+ assert np.array_equal(ad.read_h5ad(out).X.toarray(), _matrix().toarray())
+
+
+# ---------------------------------------------------------------------------
+# refusing before writing
+
+
+def test_a_lossy_cast_is_refused_and_nothing_is_written(tmp_path):
+ """The store must be left alone, not half converted."""
+ matrix = _matrix(integral=False)
+ matrix.data = matrix.data + 0.123456789012345
+ store = _store(tmp_path / "in.h5ad", matrix)
+ out = tmp_path / "out.h5ad"
+
+ result = runner.invoke(
+ app, ["convert", str(store), "X", "-o", str(out), "--dtype", "float32"]
+ )
+ assert result.exit_code == 1
+ text = _out(result)
+ assert "lossy" in text and "--force" in text
+ assert "do not round-trip" in text
+ assert not out.exists(), "a refused conversion must leave no output behind"
+
+
+def test_force_converts_anyway(tmp_path):
+ matrix = _matrix(integral=False)
+ matrix.data = matrix.data + 0.123456789012345
+ store = _store(tmp_path / "in.h5ad", matrix)
+ out = tmp_path / "out.h5ad"
+
+ result = runner.invoke(
+ app,
+ ["convert", str(store), "X", "-o", str(out), "--dtype", "float32", "--force"],
+ )
+ assert result.exit_code == 0, _out(result)
+ assert ad.read_h5ad(out).X.dtype == np.dtype("float32")
+
+
+def test_a_lossless_cast_says_so(tmp_path):
+ """Worth telling the user: it is the question they were asking."""
+ store = _store(tmp_path / "in.h5ad", _matrix())
+ result = runner.invoke(
+ app,
+ ["convert", str(store), "X", "-o", str(tmp_path / "o.h5ad"),
+ "--dtype", "float32"],
+ )
+ assert result.exit_code == 0
+ assert "round-trips" in _out(result)
+
+
+class _FakeGroup:
+ """Just enough of a sparse group for the index-range check."""
+
+ def __init__(self, nnz: int):
+ self._nnz = nnz
+
+ def __getitem__(self, key):
+ assert key == "indices"
+ return type("D", (), {"shape": (self._nnz,)})()
+
+
+def test_an_index_dtype_too_small_to_address_the_matrix_is_refused():
+ """indices and indptr are bounded by different things, so both are checked.
+
+ `indices` holds coordinates, bounded by the larger dimension; `indptr`
+ holds offsets, bounded by nnz. A matrix can legitimately need int64 for
+ one and not the other.
+ """
+ from adata.core.convert import check_index_dtype
+
+ wide = _FakeGroup(nnz=10)
+ with pytest.raises(ValueError, match="cannot hold this matrix's coordinates"):
+ check_index_dtype(
+ wide, np.dtype("int32"), np.dtype("int64"), (10, 3_000_000_000)
+ )
+
+ many = _FakeGroup(nnz=3_000_000_000)
+ with pytest.raises(ValueError, match="cannot hold this matrix's offsets"):
+ check_index_dtype(many, np.dtype("int64"), np.dtype("int32"), (10, 10))
+
+ # Narrow matrix, huge nnz: int32 coordinates are fine, offsets are not.
+ check_index_dtype(
+ _FakeGroup(nnz=10), np.dtype("int32"), np.dtype("int64"), (10, 10)
+ )
+
+
+def test_indptr_keeps_its_own_width(tmp_path):
+ """A store with int32 indices and int64 indptr must keep both.
+
+ Inferring indptr's dtype from indices narrowed the offsets of any
+ matrix with more than 2^31 nonzeros, which corrupts it silently.
+ """
+ import h5py
+
+ store = _store(tmp_path / "in.h5ad", _matrix())
+ with h5py.File(store, "a") as handle:
+ pointers = handle["X/indptr"][...]
+ coordinates = handle["X/indices"][...]
+ del handle["X/indptr"], handle["X/indices"]
+ handle["X"].create_dataset("indptr", data=pointers.astype("int64"))
+ handle["X"].create_dataset("indices", data=coordinates.astype("int32"))
+
+ out = tmp_path / "out.h5ad"
+ assert runner.invoke(
+ app, ["convert", str(store), "X", "-o", str(out), "--dtype", "float32"]
+ ).exit_code == 0
+
+ with h5py.File(out) as handle:
+ assert handle["X/indptr"].dtype == np.dtype("int64"), "offsets narrowed"
+ assert handle["X/indices"].dtype == np.dtype("int32")
+
+
+# ---------------------------------------------------------------------------
+# density
+
+
+def test_densify_then_sparsify_round_trips(tmp_path):
+ matrix = _matrix(density=0.3)
+ store = _store(tmp_path / "in.h5ad", matrix)
+
+ dense_path, sparse_path = tmp_path / "dense.h5ad", tmp_path / "sparse.h5ad"
+ assert runner.invoke(
+ app,
+ ["convert", str(store), "X", "-o", str(dense_path), "--layout", "dense",
+ "--force"],
+ ).exit_code == 0
+ got_dense = ad.read_h5ad(dense_path).X
+ assert not sparse.issparse(got_dense)
+ assert np.array_equal(_dense(got_dense), matrix.toarray())
+
+ assert runner.invoke(
+ app,
+ ["convert", str(dense_path), "X", "-o", str(sparse_path),
+ "--layout", "csr"],
+ ).exit_code == 0
+ got = ad.read_h5ad(sparse_path).X
+ assert got.format == "csr"
+ assert np.array_equal(got.toarray(), matrix.toarray())
+
+
+def test_densifying_a_sparse_matrix_is_refused_when_it_would_explode(tmp_path):
+ """0.5% dense over a wide matrix is exactly the case that hurts."""
+ matrix = _matrix(n_obs=200, n_var=2000, density=0.005)
+ store = _store(tmp_path / "in.h5ad", matrix)
+ out = tmp_path / "out.h5ad"
+
+ result = runner.invoke(
+ app, ["convert", str(store), "X", "-o", str(out), "--layout", "dense"]
+ )
+ assert result.exit_code == 1
+ text = _out(result)
+ assert "would grow" in text and "--force" in text
+ assert not out.exists()
+
+
+def test_sparsifying_a_mostly_dense_matrix_warns(tmp_path):
+ dense = np.ones((20, 10))
+ store = _store(tmp_path / "in.h5ad", sparse.csr_matrix(dense))
+ dense_path = tmp_path / "dense.h5ad"
+ assert runner.invoke(
+ app,
+ ["convert", str(store), "X", "-o", str(dense_path), "--layout", "dense",
+ "--force"],
+ ).exit_code == 0
+
+ result = runner.invoke(
+ app,
+ ["convert", str(dense_path), "X", "-o", str(tmp_path / "s.h5ad"),
+ "--layout", "csr"],
+ )
+ assert result.exit_code == 0, _out(result)
+ assert "nonzero" in _out(result)
+
+
+# ---------------------------------------------------------------------------
+# selection and plumbing
+
+
+def test_all_converts_x_layers_and_raw(tmp_path):
+ matrix = _matrix()
+ store = _store(
+ tmp_path / "in.h5ad", matrix, layers={"counts": matrix.copy()}, raw=True
+ )
+ out = tmp_path / "out.h5ad"
+
+ result = runner.invoke(
+ app, ["convert", str(store), "--all", "-o", str(out), "--layout", "csc"]
+ )
+ assert result.exit_code == 0, _out(result)
+
+ got = ad.read_h5ad(out)
+ assert got.X.format == "csc"
+ assert got.layers["counts"].format == "csc"
+ assert got.raw is not None and got.raw.X.format == "csc"
+ assert list(got.obs_names) == [f"c{i}" for i in range(matrix.shape[0])]
+
+
+def test_untargeted_elements_are_carried_over_untouched(tmp_path):
+ matrix = _matrix()
+ obj = ad.AnnData(
+ X=matrix,
+ obs=pd.DataFrame(
+ {"group": pd.Categorical(["a", "b"] * (matrix.shape[0] // 2))},
+ index=[f"c{i}" for i in range(matrix.shape[0])],
+ ),
+ var=pd.DataFrame(index=[f"g{i}" for i in range(matrix.shape[1])]),
+ )
+ obj.layers["untouched"] = matrix.copy()
+ obj.obsm["X_pca"] = np.arange(matrix.shape[0] * 3, dtype="float32").reshape(-1, 3)
+ obj.uns["note"] = "keep me"
+ store = tmp_path / "in.h5ad"
+ obj.write_h5ad(store)
+
+ out = tmp_path / "out.h5ad"
+ assert runner.invoke(
+ app, ["convert", str(store), "X", "-o", str(out), "--layout", "csc"]
+ ).exit_code == 0
+
+ got = ad.read_h5ad(out)
+ assert got.X.format == "csc"
+ assert got.layers["untouched"].format == "csr", "an untargeted layer changed"
+ assert got.uns["note"] == "keep me"
+ assert np.array_equal(got.obsm["X_pca"], obj.obsm["X_pca"])
+ assert list(got.obs["group"]) == list(obj.obs["group"])
+
+
+def test_inplace_replaces_the_source(tmp_path):
+ matrix = _matrix()
+ store = _store(tmp_path / "in.h5ad", matrix)
+
+ result = runner.invoke(
+ app, ["convert", str(store), "X", "--inplace", "--dtype", "float32"]
+ )
+ assert result.exit_code == 0, _out(result)
+
+ got = ad.read_h5ad(store)
+ assert got.X.dtype == np.dtype("float32")
+ assert np.array_equal(got.X.toarray(), matrix.toarray().astype("float32"))
+ assert not list(tmp_path.glob("*convert-tmp*")), "temp file left behind"
+
+
+def test_converting_to_zarr_keeps_the_values(tmp_path):
+ matrix = _matrix()
+ store = _store(tmp_path / "in.h5ad", matrix)
+ out = tmp_path / "out.zarr"
+
+ result = runner.invoke(
+ app, ["convert", str(store), "X", "-o", str(out), "--layout", "csc"]
+ )
+ assert result.exit_code == 0, _out(result)
+ got = ad.read_zarr(out).X
+ assert got.format == "csc"
+ assert np.array_equal(got.toarray(), matrix.toarray())
+
+
+# ---------------------------------------------------------------------------
+# argument handling
+
+
+def test_convert_needs_something_to_do(tmp_path):
+ store = _store(tmp_path / "in.h5ad", _matrix())
+ result = runner.invoke(
+ app, ["convert", str(store), "X", "-o", str(tmp_path / "o.h5ad")]
+ )
+ assert result.exit_code == 1
+ assert "at least one of --dtype" in _out(result)
+
+
+def test_convert_needs_an_output(tmp_path):
+ store = _store(tmp_path / "in.h5ad", _matrix())
+ result = runner.invoke(app, ["convert", str(store), "X", "--dtype", "float32"])
+ assert result.exit_code == 1
+ assert "Output file is required" in _out(result)
+
+
+@pytest.mark.parametrize(
+ "flag,value,expected",
+ [
+ ("--dtype", "complex128", "Unknown dtype"),
+ ("--indices-dtype", "float32", "Unknown dtype"),
+ ("--layout", "coo", "--layout must be one of"),
+ ],
+)
+def test_bad_values_are_rejected_by_name(tmp_path, flag, value, expected):
+ store = _store(tmp_path / "in.h5ad", _matrix())
+ result = runner.invoke(
+ app,
+ ["convert", str(store), "X", "-o", str(tmp_path / "o.h5ad"), flag, value],
+ )
+ assert result.exit_code == 1
+ assert expected in _out(result)
+
+
+def test_converting_something_that_is_not_a_matrix_says_so(tmp_path):
+ store = _store(tmp_path / "in.h5ad", _matrix())
+ result = runner.invoke(
+ app,
+ ["convert", str(store), "obs", "-o", str(tmp_path / "o.h5ad"),
+ "--dtype", "float32"],
+ )
+ assert result.exit_code == 1
+ assert "Not a matrix" in _out(result)
+
+
+def test_a_missing_entry_names_the_path(tmp_path):
+ store = _store(tmp_path / "in.h5ad", _matrix())
+ result = runner.invoke(
+ app,
+ ["convert", str(store), "layers/nope", "-o", str(tmp_path / "o.h5ad"),
+ "--dtype", "float32"],
+ )
+ assert result.exit_code == 1
+ assert "layers/nope" in _out(result)
+
+
+# ---------------------------------------------------------------------------
+# what review found
+#
+# Five findings on the first version of this command, four of them able to
+# change or destroy data while reporting success. Each gets a test.
+
+
+def test_an_output_that_names_the_input_is_refused(tmp_path):
+ """Writing over the store being read from destroyed it.
+
+ HDF5 happens to refuse the second open; Zarr does not, and the command
+ completed successfully leaving a store with zero nonzeros where the
+ data had been.
+ """
+ matrix = _matrix()
+ store = _store(tmp_path / "in.h5ad", matrix)
+
+ result = runner.invoke(
+ app, ["convert", str(store), "X", "-o", str(store), "--dtype", "float32"]
+ )
+ assert result.exit_code == 1
+ assert "Output path is the input" in _out(result)
+ assert np.array_equal(ad.read_h5ad(store).X.toarray(), matrix.toarray())
+
+
+def test_an_output_that_aliases_the_input_through_a_relative_path_is_refused(
+ tmp_path,
+):
+ matrix = _matrix()
+ store = _store(tmp_path / "in.h5ad", matrix)
+ alias = tmp_path / "sub" / ".." / "in.h5ad"
+ (tmp_path / "sub").mkdir()
+
+ result = runner.invoke(
+ app, ["convert", str(store), "X", "-o", str(alias), "--dtype", "float32"]
+ )
+ assert result.exit_code == 1
+ assert np.array_equal(ad.read_h5ad(store).X.toarray(), matrix.toarray())
+
+
+def test_an_explicitly_named_obsm_matrix_is_actually_converted(tmp_path):
+ """Only `layers` and `raw` were descended into, so this silently no-opped.
+
+ The command reported success and copied the matrix over unchanged,
+ which is the worst way to get this wrong: the user has no signal.
+ """
+ matrix = _matrix()
+ obj = ad.AnnData(
+ X=matrix,
+ obs=pd.DataFrame(index=[f"c{i}" for i in range(matrix.shape[0])]),
+ var=pd.DataFrame(index=[f"g{i}" for i in range(matrix.shape[1])]),
+ )
+ obj.obsm["X_pca"] = np.ones((matrix.shape[0], 4), dtype="float64")
+ store = tmp_path / "in.h5ad"
+ obj.write_h5ad(store)
+
+ out = tmp_path / "out.h5ad"
+ result = runner.invoke(
+ app,
+ ["convert", str(store), "obsm/X_pca", "-o", str(out), "--dtype", "float32"],
+ )
+ assert result.exit_code == 0, _out(result)
+
+ got = ad.read_h5ad(out)
+ assert got.obsm["X_pca"].dtype == np.dtype("float32")
+ assert np.array_equal(got.obsm["X_pca"], obj.obsm["X_pca"].astype("float32"))
+ assert got.X.dtype == matrix.dtype, "X should not have been touched"
+
+
+def test_densifying_sums_duplicate_coordinates(tmp_path):
+ """A repeated coordinate means the sum, which is what scipy produces.
+
+ Legal in a CSR store and not what anndata writes, so it takes a
+ hand-built file to reach -- but plain assignment kept whichever entry
+ came last and changed the matrix's values on the way to dense.
+ """
+ import h5py
+
+ store = _store(tmp_path / "in.h5ad", sparse.csr_matrix(np.zeros((2, 3))))
+ with h5py.File(store, "a") as handle:
+ for key in ("data", "indices", "indptr"):
+ del handle["X"][key]
+ handle["X"].create_dataset("data", data=np.array([1.0, 2.0]))
+ handle["X"].create_dataset("indices", data=np.array([1, 1]))
+ handle["X"].create_dataset("indptr", data=np.array([0, 2, 2]))
+
+ assert ad.read_h5ad(store).X.toarray()[0, 1] == 3.0, "scipy sums them"
+
+ out = tmp_path / "dense.h5ad"
+ assert runner.invoke(
+ app,
+ ["convert", str(store), "X", "-o", str(out), "--layout", "dense",
+ "--force"],
+ ).exit_code == 0
+ assert np.asarray(ad.read_h5ad(out).X)[0, 1] == 3.0
+
+
+def test_transpose_buckets_are_balanced_by_nonzeros_not_by_coordinate(
+ tmp_path, monkeypatch
+):
+ """A skewed matrix must not land in one bucket.
+
+ Single-cell matrices are skewed -- a few genes carry most of the counts
+ -- so equal-width coordinate bounds defeat the streaming guarantee
+ exactly where it matters. Measured on the matrix below, the largest of
+ three equal-width buckets held 88% of the entries, so the bucket rather
+ than the chunk set the peak.
+ """
+ import adata.core.subset as subset_module
+ from adata.core.convert import describe, transpose_sparse_streaming
+ from adata.storage import open_store
+
+ rng = np.random.default_rng(0)
+ n = 400
+ rows, cols = [], []
+ for row in range(n):
+ for _ in range(20):
+ rows.append(row)
+ cols.append(
+ int(rng.integers(0, 5)) if rng.random() < 0.95
+ else int(rng.integers(0, n))
+ )
+ skewed = sparse.csr_matrix((np.ones(len(rows)), (rows, cols)), shape=(n, n))
+ store = _store(tmp_path / "skew.h5ad", skewed)
+
+ # `transpose_sparse_streaming` imports `_append` from this module when it
+ # runs, so this is the binding it will pick up.
+ per_bucket: dict = {}
+ original = subset_module._append
+
+ def watching_append(dataset, values):
+ name = str(getattr(dataset, "name", "") or getattr(dataset, "path", ""))
+ if "major" in name:
+ per_bucket[name] = per_bucket.get(name, 0) + int(values.size)
+ return original(dataset, values)
+
+ monkeypatch.setattr(subset_module, "_append", watching_append)
+
+ out = tmp_path / "out.h5ad"
+ with open_store(store, "r") as src, open_store(out, "w") as dst:
+ transpose_sparse_streaming(
+ describe(src.root["X"]),
+ dst.root,
+ "X",
+ data_dtype=np.dtype("float64"),
+ index_dtype=np.dtype("int64"),
+ chunk=1000,
+ bucket_entries=1000,
+ )
+
+ sizes = sorted(per_bucket.values(), reverse=True)
+ assert len(sizes) > 1, f"expected several buckets, saw {per_bucket}"
+ assert sizes[0] <= skewed.nnz * 0.5, (
+ f"the largest bucket held {sizes[0]} of {skewed.nnz} nonzeros "
+ f"({sizes[0] / skewed.nnz:.0%}); buckets must be balanced by count, "
+ "not by coordinate range"
+ )
+
+ # Read X back directly: this wrote only the matrix, not a whole store.
+ import h5py
+
+ expected = skewed.tocsc()
+ with h5py.File(out) as handle:
+ got = sparse.csc_matrix(
+ (handle["X/data"][...], handle["X/indices"][...],
+ handle["X/indptr"][...]),
+ shape=tuple(handle["X"].attrs["shape"]),
+ )
+ assert np.array_equal(got.toarray(), expected.toarray()), (
+ "balancing the buckets changed the result"
+ )
diff --git a/tests/test_docs_are_accurate.py b/tests/test_docs_are_accurate.py
index d474a34..f6f4608 100644
--- a/tests/test_docs_are_accurate.py
+++ b/tests/test_docs_are_accurate.py
@@ -100,7 +100,8 @@ def _command_path(tokens: List[str]) -> List[str]:
_GROUPS = {"export", "import"}
_TOP_LEVEL = {
- "view", "ls", "subset", "split", "concat", "create", "export", "import",
+ "view", "ls", "subset", "split", "concat", "convert", "create",
+ "export", "import",
}
_SUBCOMMANDS = {
"export": {"dataframe", "array", "sparse", "dict", "image"},
diff --git a/tests/test_performance.py b/tests/test_performance.py
new file mode 100644
index 0000000..f272055
--- /dev/null
+++ b/tests/test_performance.py
@@ -0,0 +1,1263 @@
+"""Complexity guards: what an operation *costs*, never how long it takes.
+
+Why this file exists
+--------------------
+`concat --merge` hung for hours on a 36,601-var input (REQ-71798). One line in
+`_write_var` re-read a whole var column from disk once per target variable.
+The output would have been correct; only the cost was wrong. 1,029 tests
+missed it, and they were never going to catch it: every fixture in the suite
+is at most a few hundred elements, and nothing measured cost at all.
+
+So this file closes a defect class, not a bug. The instruments are in
+`tests/perf_counters.py`; the rule they enforce is that cost must grow no
+faster than linearly in each axis, measured in operations rather than seconds.
+
+Why not wall clock
+------------------
+A test that can fail because a CI runner was busy does not belong in a merge
+gate. Every number here is deterministic: the same input produces the same
+count on every machine. That is what lets these run in the ordinary test job
+on both interpreters and block a merge when they fail.
+
+Reading a failure
+-----------------
+A failure here says cost grew super-linearly on the named axis. Usually the
+code is wrong. Occasionally the expectation is -- an operation legitimately
+gains work -- and then the new number needs a comment saying why, in the same
+style as the exact counts in `test_commands_phase2.py`.
+
+One axis at a time. Scaling two at once makes legitimate work look quadratic:
+an outer-join concat really does produce n_obs x n_var_union cells.
+"""
+
+from __future__ import annotations
+
+import pathlib
+from pathlib import Path
+from typing import List, Optional
+
+import numpy as np
+import pytest
+from rich.console import Console
+
+from adata.commands.create import create_store
+from adata.commands.export import (
+ export_json,
+ export_mtx,
+ export_npy,
+ export_table,
+)
+from adata.commands.import_data import import_object
+from adata.commands.info import show_info
+from adata.commands.ls import list_store
+from adata.core.concat import concat_on_disk
+from adata.core.subset import subset_h5ad
+
+from tests.perf_counters import (
+ GROWTH_LIMIT,
+ SIZES,
+ assert_grows_linearly,
+ assert_grows_slower_than_input,
+ assert_independent_of,
+ count_allocations,
+ count_scanned_elements,
+ count_io,
+ count_lines,
+)
+
+ad = pytest.importorskip("anndata", reason="anndata builds the fixtures")
+pd = pytest.importorskip("pandas")
+sparse = pytest.importorskip("scipy.sparse")
+
+QUIET = Console(quiet=True)
+
+#: Only Python executed inside the package counts towards the CPU proxy.
+SOURCE_PREFIX = str(pathlib.Path(__file__).resolve().parent.parent / "src" / "adata")
+
+
+# ---------------------------------------------------------------------------
+# fixtures built to scale exactly one axis
+
+
+def _store(
+ path: Path,
+ *,
+ name: str,
+ n_obs: int = 4,
+ n_var: int = 8,
+ n_var_columns: int = 2,
+ n_obs_columns: int = 0,
+ n_categories: int = 0,
+ obs_kind: Optional[str] = None,
+ shared_var: bool = True,
+) -> Path:
+ """One input store. Every argument is an axis a guard can scale."""
+ obs = pd.DataFrame(index=[f"{name}c{i}" for i in range(n_obs)])
+ if n_categories:
+ obs["ct"] = pd.Categorical(
+ [f"{name}-t{i % n_categories}" for i in range(n_obs)]
+ )
+ if obs_kind == "numeric":
+ obs["col"] = np.arange(n_obs, dtype="int32")
+ elif obs_kind == "masked":
+ obs["col"] = pd.array(
+ [i if i % 2 else None for i in range(n_obs)], dtype="Int32"
+ )
+ elif obs_kind == "string":
+ obs["col"] = [f"{name}-{i}" for i in range(n_obs)]
+ if n_obs_columns:
+ extra = pd.DataFrame(
+ {f"m{c}": np.arange(n_obs, dtype="int32") for c in range(n_obs_columns)},
+ index=obs.index,
+ )
+ obs = pd.concat([obs, extra], axis=1)
+
+ prefix = "" if shared_var else name
+ var = pd.DataFrame(
+ {f"v{c}": [f"{c}-{i}" for i in range(n_var)] for c in range(n_var_columns)},
+ index=[f"{prefix}g{i}" for i in range(n_var)],
+ )
+
+ obj = ad.AnnData(
+ X=sparse.csr_matrix(np.ones((n_obs, n_var), dtype="float32")),
+ obs=obs,
+ var=var,
+ )
+ obj.obsm["X_pca"] = np.zeros((n_obs, 3), dtype="float32")
+ obj.write_h5ad(path)
+ return path
+
+
+def _inputs(tmp_path: Path, tag: str, count: int = 2, **kwargs) -> List[Path]:
+ directory = tmp_path / tag
+ directory.mkdir(parents=True, exist_ok=True)
+ return [
+ _store(directory / f"{chr(ord('a') + i)}.h5ad", name=chr(ord("a") + i), **kwargs)
+ for i in range(count)
+ ]
+
+
+# ---------------------------------------------------------------------------
+# canary
+#
+# Every growth guard below is a comparison between measurements. If a refactor
+# moves a read onto a path the counters cannot see -- `read_direct`,
+# `np.asarray(dataset)`, `asstr()[...]`; see perf_counters' docstring -- the
+# comparisons would all pass on zeros. The vacuity check inside
+# `assert_grows_linearly` catches most of that; this catches the rest, and
+# says plainly what broke.
+
+
+def test_the_counters_see_a_known_operation(tmp_path):
+ """A concat of two 4 x 64 stores must register substantial work.
+
+ If this fails, the hooks have stopped matching the libraries and every
+ other test in this file has become meaningless. Fix the hooks first.
+ """
+ files = _inputs(tmp_path, "canary", n_obs=4, n_var=64)
+ with count_io() as io:
+ concat_on_disk(files, tmp_path / "out.h5ad", QUIET, merge="same")
+
+ assert io.h5_calls > 0, "h5py.Dataset.__getitem__ hook is not firing"
+ assert io.h5_elements >= 64, f"suspiciously few elements read: {io}"
+
+ with count_allocations() as peak:
+ concat_on_disk(files, tmp_path / "out2.h5ad", QUIET, merge="same")
+ assert peak[0] > 0, "tracemalloc recorded no allocation"
+
+ with count_lines(SOURCE_PREFIX) as lines:
+ concat_on_disk(files, tmp_path / "out3.h5ad", QUIET, merge="same")
+ assert lines[0] > 100, f"line tracer saw only {lines[0]} events in src/adata"
+
+
+def test_the_zarr_store_hooks_fire(tmp_path):
+ """Metadata and chunk traffic must be visible, not just array reads.
+
+ An attribute-write storm never touches `Array.__getitem__` -- it is all
+ `LocalStore.set`. That shape of bug has bitten this repo before, so the
+ store hooks get their own canary.
+ """
+ files = _inputs(tmp_path, "zcanary", n_obs=4, n_var=32)
+ with count_io() as io:
+ concat_on_disk(files, tmp_path / "out.zarr", QUIET, merge="same")
+
+ assert io.store_set > 0, "LocalStore.set hook is not firing"
+ assert io.store_get > 0, "LocalStore.get hook is not firing"
+
+
+# ---------------------------------------------------------------------------
+# concat: reads must not grow faster than linearly on any axis
+#
+# The n_var case is the direct regression guard for REQ-71798. The others
+# exist because concat's cost surface is n_obs x n_var x n_columns x n_inputs
+# and a defect can hide in any of them; scaling only the axis that broke last
+# time would be fighting the last war.
+
+
+@pytest.mark.parametrize("merge", [None, "same", "unique", "first", "only"])
+def test_concat_reads_grow_linearly_in_var_count(tmp_path, merge):
+ """The REQ-71798 axis. Every merge strategy, not just the one reported."""
+
+ def measure(n: int) -> int:
+ files = _inputs(tmp_path, f"var{merge}{n}", n_var=n)
+ with count_io() as io:
+ concat_on_disk(files, tmp_path / f"o{merge}{n}.h5ad", QUIET, merge=merge)
+ return io.reads
+
+ assert_grows_linearly(measure, what=f"concat --merge {merge}", axis="n_var")
+
+
+@pytest.mark.parametrize("join", ["inner", "outer"])
+def test_concat_reads_grow_linearly_in_obs_count(tmp_path, join):
+ def measure(n: int) -> int:
+ files = _inputs(tmp_path, f"obs{join}{n}", n_obs=n, n_var=4)
+ with count_io() as io:
+ concat_on_disk(files, tmp_path / f"o{join}{n}.h5ad", QUIET, join=join)
+ return io.reads
+
+ assert_grows_linearly(measure, what=f"concat --join {join}", axis="n_obs")
+
+
+def test_concat_reads_grow_linearly_in_input_count(tmp_path):
+ """Pairwise work across inputs would show up here and nowhere else."""
+
+ def measure(n: int) -> int:
+ files = _inputs(tmp_path, f"in{n}", count=n, n_obs=2, n_var=4)
+ with count_io() as io:
+ concat_on_disk(files, tmp_path / f"oin{n}.h5ad", QUIET, merge="same")
+ return io.reads
+
+ # Sizes are scaled down: n inputs means n files on disk.
+ assert_grows_linearly(
+ measure, what="concat", axis="n_inputs", sizes=(4, 16, 64)
+ )
+
+
+def test_concat_reads_grow_linearly_in_obs_column_count(tmp_path):
+ def measure(n: int) -> int:
+ files = _inputs(tmp_path, f"oc{n}", n_obs=8, n_var=4, n_obs_columns=n)
+ with count_io() as io:
+ concat_on_disk(files, tmp_path / f"ooc{n}.h5ad", QUIET)
+ return io.reads
+
+ assert_grows_linearly(measure, what="concat", axis="n_obs_columns")
+
+
+def test_concat_reads_grow_linearly_in_var_column_count(tmp_path):
+ def measure(n: int) -> int:
+ files = _inputs(tmp_path, f"vc{n}", n_obs=4, n_var=8, n_var_columns=n)
+ with count_io() as io:
+ concat_on_disk(tmp_path / f"vc{n}" and files, tmp_path / f"ovc{n}.h5ad",
+ QUIET, merge="same")
+ return io.reads
+
+ assert_grows_linearly(measure, what="concat --merge same", axis="n_var_columns")
+
+
+# ---------------------------------------------------------------------------
+# concat: the category union
+#
+# This one needs its own instrument. Merging categories reads each category
+# list exactly once however the union is computed, so a read counter sees
+# nothing; and `x not in some_list` is a single bytecode, so the line tracer
+# sees nothing either -- the quadratic lives inside C-level list membership.
+# Counting string comparisons is the only thing that makes it visible.
+
+
+class _CountingStr(str):
+ """A string that tallies its own comparisons.
+
+ `list.__contains__` rich-compares each element, and a subclass's `__eq__`
+ takes priority, so the tally reflects real membership-probe cost.
+ """
+
+ count = 0
+
+ def __eq__(self, other: object) -> bool:
+ _CountingStr.count += 1
+ return str.__eq__(self, other)
+
+ def __ne__(self, other: object) -> bool:
+ return not self.__eq__(other)
+
+ def __hash__(self) -> int:
+ return str.__hash__(self)
+
+
+def _count_category_comparisons(monkeypatch, tmp_path, k: int) -> int:
+ import adata.core.concat as concat_module
+
+ original = concat_module.read_categories
+ monkeypatch.setattr(
+ concat_module,
+ "read_categories",
+ lambda col: [_CountingStr(c) for c in original(col)],
+ )
+
+ files = _inputs(tmp_path, f"cat{k}", n_obs=k, n_var=2, n_categories=k)
+ _CountingStr.count = 0
+ concat_on_disk(files, tmp_path / f"ocat{k}.h5ad", QUIET)
+ return _CountingStr.count
+
+
+def test_concat_unions_categories_without_quadratic_comparisons(
+ tmp_path, monkeypatch
+):
+ """Category merging must use hash lookup, not list membership.
+
+ `if category not in categories` on a list was O(k) per probe and so
+ O(k^2) over the union: 2,096,128 string comparisons at k=1024, and around
+ 5e9 for an obs column with 100k categories -- the same silent hang as
+ REQ-71798, in a different function.
+
+ A dict makes it O(k) with comparisons only on hash collision, so the
+ budget is generous and still three orders of magnitude below the old cost.
+ """
+ k = 1024
+ comparisons = _count_category_comparisons(monkeypatch, tmp_path, k)
+ assert comparisons <= 10 * k, (
+ f"{comparisons} string comparisons to union {k} categories across two "
+ f"inputs. Linear would be near zero (hash collisions only); the "
+ f"list-membership implementation cost 2,096,128."
+ )
+
+
+# ---------------------------------------------------------------------------
+# concat: per-row cost of an obs column
+#
+# Growth ratios cannot see these: building a Python object per row is linear,
+# just with a fat constant. Comparing a column kind against a plain numeric
+# column in the same run calibrates that constant away, so the assertion holds
+# across interpreters and platforms without a hand-tuned byte budget.
+
+
+def _obs_column_cost(tmp_path, kind: str, n_obs: int) -> tuple:
+ files = _inputs(tmp_path, f"k{kind}{n_obs}", n_obs=n_obs, n_var=2, obs_kind=kind)
+ with count_lines(SOURCE_PREFIX) as lines, count_allocations() as peak:
+ concat_on_disk(files, tmp_path / f"ok{kind}{n_obs}.h5ad", QUIET)
+ return lines[0], peak[0]
+
+
+@pytest.mark.perf
+@pytest.mark.parametrize("kind", ["masked", "string"])
+def test_obs_columns_cost_no_more_python_work_per_row_than_a_numeric_one(
+ tmp_path, kind
+):
+ """No Python-level loop over rows when concatenating an obs column.
+
+ `_concat_masked` used to fill a `List[Any]` of length n_obs one element at
+ a time and then walk it twice more. That is invisible to a read counter
+ and to a growth ratio, but it shows up immediately as executed line events
+ per row relative to the numeric path, which has always been vectorised.
+
+ Measured after the fix: masked is 1.00x numeric, string 1.35x (the string
+ path still materialises Python `str` objects via `read_str_all`, which is
+ inherent to how strings are read, not a per-row loop). Before the fix
+ masked was above 1.2x.
+ """
+ n_obs = 4000
+ numeric_lines, _ = _obs_column_cost(tmp_path, "numeric", n_obs)
+ kind_lines, _ = _obs_column_cost(tmp_path, kind, n_obs)
+
+ limit = 1.10 if kind == "masked" else 1.45
+ ratio = kind_lines / numeric_lines
+ assert ratio <= limit, (
+ f"a {kind} obs column executes {ratio:.2f}x the Python lines of a "
+ f"numeric one ({kind_lines} vs {numeric_lines} at n_obs={n_obs}); "
+ f"limit {limit}. Look for a per-element loop over rows."
+ )
+
+
+@pytest.mark.parametrize("kind", ["numeric", "masked", "string"])
+def test_obs_column_allocation_grows_linearly(tmp_path, kind):
+ """Peak allocation must not grow faster than the data itself.
+
+ Note what this does *not* claim: obs columns are read whole, so peak
+ allocation is O(n_obs) and not O(chunk). The streaming guarantee that the
+ README makes holds for X, not for obs annotation. `benchmarks/` reports
+ the real curve; this only stops it getting worse than linear.
+ """
+
+ def measure(n: int) -> int:
+ _, peak = _obs_column_cost(tmp_path, kind, n)
+ return peak
+
+ assert_grows_linearly(
+ measure,
+ what=f"concat allocation, {kind} obs column",
+ axis="n_obs",
+ sizes=(256, 1024, 4096),
+ )
+
+
+# ---------------------------------------------------------------------------
+# subset, split and conversion
+#
+# concat is where the defect was found, but nothing about the defect was
+# specific to concat: every command that aligns one index onto another has the
+# same opportunity to re-read a column per element.
+
+
+@pytest.mark.parametrize("axis", ["obs", "var"])
+def test_subset_by_name_reads_grow_linearly(tmp_path, axis):
+ def measure(n: int) -> int:
+ source = _store(
+ tmp_path / f"s{axis}{n}.h5ad",
+ name="s",
+ n_obs=n if axis == "obs" else 4,
+ n_var=n if axis == "var" else 4,
+ )
+ # A name file, not indices: resolving names onto the stored index is
+ # exactly the kind of alignment that went quadratic in concat.
+ names = [f"sc{i}" for i in range(0, n, 2)] if axis == "obs" else [
+ f"g{i}" for i in range(0, n, 2)
+ ]
+ name_file = tmp_path / f"n{axis}{n}.txt"
+ name_file.write_text("\n".join(names) + "\n")
+ with count_io() as io:
+ subset_h5ad(
+ source,
+ tmp_path / f"os{axis}{n}.h5ad",
+ name_file if axis == "obs" else None,
+ name_file if axis == "var" else None,
+ console=QUIET,
+ )
+ return io.reads
+
+ assert_grows_linearly(
+ measure, what=f"subset --{axis}", axis=f"n_{axis}", sizes=(64, 256, 1024)
+ )
+
+
+def test_subset_by_query_reads_grow_linearly(tmp_path):
+ def measure(n: int) -> int:
+ source = _store(
+ tmp_path / f"q{n}.h5ad", name="q", n_obs=n, n_var=4, obs_kind="numeric"
+ )
+ with count_io() as io:
+ subset_h5ad(
+ source,
+ tmp_path / f"oq{n}.h5ad",
+ None,
+ None,
+ console=QUIET,
+ obs_query="col >= 0",
+ )
+ return io.reads
+
+ assert_grows_linearly(measure, what="subset --obs-query", axis="n_obs")
+
+
+def test_h5ad_to_zarr_conversion_reads_grow_linearly(tmp_path):
+ def measure(n: int) -> int:
+ source = _store(tmp_path / f"c{n}.h5ad", name="c", n_obs=n, n_var=4)
+ # subset is also the conversion path, but it insists on a selection;
+ # selecting every row is the identity and still rewrites the store.
+ keep = tmp_path / f"ck{n}.txt"
+ keep.write_text("\n".join(f"cc{i}" for i in range(n)) + "\n")
+ with count_io() as io:
+ subset_h5ad(
+ source, tmp_path / f"oc{n}.zarr", keep, None, console=QUIET
+ )
+ return io.reads
+
+ assert_grows_linearly(measure, what="h5ad to zarr", axis="n_obs")
+
+
+def test_split_reads_grow_linearly_in_group_count(tmp_path):
+ """Splitting into k groups must stay linear in k at fixed store size.
+
+ n_obs is held constant deliberately. Scaling rows alongside groups would
+ make the total legitimately quadratic -- k passes over 4k rows -- and the
+ guard would be measuring the fixture, not the implementation.
+ """
+ from adata.commands.split import split_store
+
+ n_obs = 512
+
+ def measure(n: int) -> int:
+ source = _store(
+ tmp_path / f"sp{n}.h5ad",
+ name="sp",
+ n_obs=n_obs,
+ n_var=4,
+ n_categories=n,
+ )
+ out = tmp_path / f"osp{n}"
+ with count_io() as io:
+ split_store(source, "ct", out, QUIET, axis="obs", manifest=False)
+ return io.reads
+
+ assert_grows_linearly(
+ measure, what="split --by", axis="n_groups", sizes=(8, 32, 128)
+ )
+
+
+# ---------------------------------------------------------------------------
+# the constants themselves
+
+
+def test_growth_limit_separates_linear_from_super_linear():
+ """Documenting why K is 6, as an executable statement rather than a note.
+
+ At the 4x spacing in SIZES the increment ratio is 4.0 for linear work,
+ about 4.4 for n log n, 8 for n**1.5 and 16 for quadratic.
+ """
+ small, mid, large = SIZES
+ assert mid == small * 4 and large == mid * 4, (
+ "GROWTH_LIMIT is calibrated for 4x spacing; changing SIZES without "
+ "recomputing it invalidates every guard in this file."
+ )
+
+ def ratio(exponent: float) -> float:
+ cost = [float(n) ** exponent for n in SIZES]
+ return (cost[2] - cost[1]) / (cost[1] - cost[0])
+
+ assert ratio(1.0) < GROWTH_LIMIT, "linear work must pass"
+ assert ratio(1.0) == pytest.approx(4.0, rel=0.01)
+ assert ratio(2.0) > GROWTH_LIMIT, "quadratic work must fail"
+ assert ratio(1.5) > GROWTH_LIMIT, "n**1.5 must fail"
+
+
+# ---------------------------------------------------------------------------
+# inspection is free
+#
+# `view` and `ls` reach only `.shape`, `.dtype` and `.attrs`: `axis_len` goes
+# through `element_len`, which reads a shape, and `_array_details` and
+# `_infer_untagged` never touch a value. That is the whole reason they return
+# instantly on a store far too large to open, and it is the one claim in the
+# README that can be stated as an exact number rather than a ratio.
+
+
+def _reads_for_inspection(path: Path, run) -> "object":
+ with count_io() as io:
+ run(path)
+ return io
+
+
+@pytest.mark.parametrize("n_obs", [64, 4096])
+@pytest.mark.parametrize(
+ "label,run",
+ [
+ ("view", lambda p: show_info(p, QUIET, out_console=QUIET)),
+ ("view --types", lambda p: show_info(p, QUIET, show_types=True,
+ out_console=QUIET)),
+ ("ls", lambda p: list_store(p, QUIET)),
+ ("ls --long", lambda p: list_store(p, QUIET, long=True)),
+ ("ls --plain", lambda p: list_store(p, QUIET, plain=True)),
+ ],
+ ids=lambda v: v if isinstance(v, str) else "",
+)
+def test_inspection_reads_no_data_at_all(tmp_path, n_obs, label, run):
+ """Not "grows slowly" -- zero. An exact count, so it needs no tolerance.
+
+ A ratio would be the wrong instrument here: 0 to 0 is not a meaningful
+ ratio, and the moment inspection reads *one* column the answer stops
+ being zero regardless of how the store scales.
+ """
+ source = _store(tmp_path / f"i{label}{n_obs}.h5ad", name="i", n_obs=n_obs,
+ n_var=16, obs_kind="numeric", n_categories=4)
+ io = _reads_for_inspection(source, run)
+
+ assert io.elements == 0, (
+ f"`{label}` read {io.elements} data elements from a {n_obs}-row store; "
+ f"inspection is supposed to touch only shapes and attributes. "
+ f"Largest reader: {max(io.by_name.items(), key=lambda kv: kv[1], default=('-', 0))}"
+ )
+
+
+def test_the_inspection_fixture_really_does_hold_readable_data(tmp_path):
+ """Keeps the test above from passing because there was nothing to read.
+
+ Same store, read by a command that is supposed to read: if this registers
+ nothing either, the fixture or the hooks are broken, not the claim.
+ """
+ source = _store(tmp_path / "control.h5ad", name="i", n_obs=4096, n_var=16,
+ obs_kind="numeric", n_categories=4)
+ with count_io() as io:
+ export_table(source, "obs", None, tmp_path / "control.csv", 10_000, None, QUIET)
+
+ assert io.elements >= 4096, (
+ f"the control read only {io.elements} elements from a 4096-row store, "
+ "so the zero above proves nothing"
+ )
+
+
+# ---------------------------------------------------------------------------
+# grouping work does not depend on the number of groups
+#
+# `split --by` groups rows through `core.select.group_indices`. Its cost is
+# invisible to every other instrument in this file: the chunk is already in
+# memory so no read counter moves, nothing lasting is allocated, and the
+# Python line count per label is constant. Only counting what is handed to
+# numpy shows it.
+
+
+def test_grouping_does_not_rescan_the_column_once_per_group(tmp_path):
+ """One pass over the column, however many distinct values it holds.
+
+ Scanning per label is O(n_rows * n_groups): measured at n_obs=4096 it was
+ 16,384 elements for 4 groups and 1,048,576 for 256 -- exactly n_rows per
+ group. On a million cells split by a thousand samples that is 10^9
+ comparisons, and `split` would appear to hang for the same reason
+ `concat --merge` did.
+
+ n_obs is fixed. The claim is about work per row, not total work.
+ """
+ from adata.core.select import group_indices
+ from adata.storage import open_store
+
+ n_obs = 4096
+
+ def measure(n_groups: int) -> int:
+ source = _store(
+ tmp_path / f"g{n_groups}.h5ad",
+ name="g",
+ n_obs=n_obs,
+ n_var=2,
+ n_categories=n_groups,
+ )
+ with open_store(source, "r") as store, count_scanned_elements() as scanned:
+ groups, order = group_indices(store.root, "obs", "ct")
+ assert len(order) == n_groups, "fixture did not produce the groups asked for"
+ # A floor, not a measurement -- see count_scanned_elements. If the
+ # implementation stops calling numpy by name the count collapses to
+ # zero and the ratio below would pass for the wrong reason.
+ assert scanned[0] >= n_obs, (
+ f"only {scanned[0]} elements scanned for {n_obs} rows; the counter "
+ "is no longer seeing this code path"
+ )
+ return scanned[0]
+
+ assert_independent_of(
+ measure, what="group_indices", axis="n_groups", sizes=(4, 256)
+ )
+
+
+# ---------------------------------------------------------------------------
+# export
+#
+# Each export reads the thing it is asked for and nothing else. The axis
+# differs per subcommand -- rows, elements, nonzeros, keys -- so each says
+# which one it scales and holds the rest fixed.
+
+
+def test_export_dataframe_reads_grow_linearly_in_rows(tmp_path):
+ def measure(n: int) -> int:
+ source = _store(tmp_path / f"ed{n}.h5ad", name="e", n_obs=n, n_var=4,
+ obs_kind="numeric")
+ with count_io() as io:
+ export_table(source, "obs", None, tmp_path / f"ed{n}.csv",
+ 10_000, None, QUIET)
+ return io.reads
+
+ assert_grows_linearly(measure, what="export dataframe", axis="n_obs")
+
+
+def test_export_dataframe_reads_grow_linearly_in_column_count(tmp_path):
+ def measure(n: int) -> int:
+ source = _store(tmp_path / f"ec{n}.h5ad", name="e", n_obs=16, n_var=4,
+ n_obs_columns=n)
+ with count_io() as io:
+ export_table(source, "obs", None, tmp_path / f"ec{n}.csv",
+ 10_000, None, QUIET)
+ return io.reads
+
+ assert_grows_linearly(measure, what="export dataframe", axis="n_obs_columns")
+
+
+def test_export_array_reads_grow_linearly_in_element_count(tmp_path):
+ def measure(n: int) -> int:
+ source = _store(tmp_path / f"ea{n}.h5ad", name="e", n_obs=n, n_var=4)
+ with count_io() as io:
+ export_npy(source, "obsm/X_pca", tmp_path / f"ea{n}.npy",
+ 100_000, QUIET)
+ return io.reads
+
+ assert_grows_linearly(measure, what="export array", axis="n_obs")
+
+
+@pytest.mark.parametrize("in_memory", [False, True])
+def test_export_sparse_reads_grow_linearly_in_nonzeros(tmp_path, in_memory):
+ """Both paths: the streamed one and the one that loads the matrix."""
+
+ def measure(n: int) -> int:
+ source = _store(tmp_path / f"es{in_memory}{n}.h5ad", name="e",
+ n_obs=n, n_var=4)
+ with count_io() as io:
+ export_mtx(source, "X", tmp_path / f"es{in_memory}{n}.mtx",
+ None, 1_000, in_memory, QUIET)
+ return io.reads
+
+ assert_grows_linearly(
+ measure, what=f"export sparse (in_memory={in_memory})", axis="nnz"
+ )
+
+
+def test_export_dict_reads_grow_linearly_in_key_count(tmp_path):
+ def measure(n: int) -> int:
+ source = _store_with_uns_keys(tmp_path / f"ej{n}.h5ad", n)
+ with count_io() as io:
+ export_json(source, "uns", tmp_path / f"ej{n}.json",
+ 100_000, False, QUIET)
+ return io.reads
+
+ assert_grows_linearly(
+ measure, what="export dict", axis="n_keys", sizes=(64, 256, 1024)
+ )
+
+
+def test_export_image_reads_grow_linearly_in_pixels(tmp_path):
+ from adata.commands.export import export_image
+
+ def measure(n: int) -> int:
+ source = _store_with_image(tmp_path / f"ei{n}.h5ad", n)
+ with count_io() as io:
+ export_image(source, "uns/picture", tmp_path / f"ei{n}.png", QUIET)
+ return io.reads
+
+ # n is the side of a square image, so pixels grow as n**2 -- the axis
+ # being scaled is the pixel count, and the sizes below keep it at 4x.
+ assert_grows_linearly(
+ measure, what="export image", axis="pixels", sizes=(16, 32, 64)
+ )
+
+
+# ---------------------------------------------------------------------------
+# import
+
+
+def test_import_dataframe_reads_grow_linearly_in_rows(tmp_path):
+ def measure(n: int) -> int:
+ source = _store(tmp_path / f"id{n}.h5ad", name="m", n_obs=n, n_var=4)
+ csv = tmp_path / f"id{n}.csv"
+ pd.DataFrame(
+ {"score": np.arange(n, dtype="int32")},
+ index=[f"mc{i}" for i in range(n)],
+ ).to_csv(csv)
+ with count_io() as io:
+ import_object(source, "obs", csv, tmp_path / f"od{n}.h5ad",
+ False, None, QUIET)
+ # `work`, not `reads`: import is a write path, and measuring only
+ # reads made this vacuous.
+ return io.work
+
+ assert_grows_linearly(measure, what="import dataframe", axis="n_obs")
+
+
+def test_import_array_reads_grow_linearly_in_element_count(tmp_path):
+ def measure(n: int) -> int:
+ source = _store(tmp_path / f"ia{n}.h5ad", name="m", n_obs=n, n_var=4)
+ npy = tmp_path / f"ia{n}.npy"
+ np.save(npy, np.zeros((n, 3), dtype="float32"))
+ with count_io() as io:
+ import_object(source, "obsm/imported", npy,
+ tmp_path / f"oa{n}.h5ad", False, None, QUIET)
+ return io.work
+
+ assert_grows_linearly(measure, what="import array", axis="n_obs")
+
+
+def test_import_sparse_reads_grow_linearly_in_nonzeros(tmp_path):
+ def measure(n: int) -> int:
+ source = _store(tmp_path / f"is{n}.h5ad", name="m", n_obs=n, n_var=4)
+ mtx = tmp_path / f"is{n}.mtx"
+ sparse_io = pytest.importorskip("scipy.io")
+ sparse_io.mmwrite(
+ str(mtx), sparse.csr_matrix(np.ones((n, 4), dtype="float32"))
+ )
+ with count_io() as io:
+ import_object(source, "layers/imported", mtx,
+ tmp_path / f"os{n}.h5ad", False, None, QUIET)
+ return io.work
+
+ assert_grows_linearly(measure, what="import sparse", axis="nnz")
+
+
+def test_import_dict_reads_grow_linearly_in_key_count(tmp_path):
+ import json
+
+ def measure(n: int) -> int:
+ source = _store(tmp_path / f"ij{n}.h5ad", name="m", n_obs=8, n_var=4)
+ blob = tmp_path / f"ij{n}.json"
+ blob.write_text(json.dumps({f"k{i}": i for i in range(n)}))
+ with count_io() as io:
+ import_object(source, "uns/imported", blob,
+ tmp_path / f"oj{n}.h5ad", False, None, QUIET)
+ return io.work
+
+ assert_grows_linearly(
+ measure, what="import dict", axis="n_keys", sizes=(64, 256, 1024)
+ )
+
+
+def test_import_image_reads_grow_linearly_in_pixels(tmp_path):
+ """Images take their own entry point.
+
+ `import_object` dispatches on extension and `.png` is deliberately not in
+ `EXTENSION_FORMAT`; the CLI's `import image` calls `_import_image`
+ directly, and it edits in place rather than writing a copy.
+ """
+ Image = pytest.importorskip("PIL.Image")
+ from adata.commands.import_data import _import_image
+
+ def measure(n: int) -> int:
+ source = _store(tmp_path / f"ii{n}.h5ad", name="m", n_obs=8, n_var=4)
+ png = tmp_path / f"ii{n}.png"
+ Image.fromarray(np.zeros((n, n, 3), dtype="uint8")).save(png)
+ with count_io() as io:
+ _import_image(source, "uns/picture", png, QUIET)
+ return io.work
+
+ assert_grows_linearly(
+ measure, what="import image", axis="pixels", sizes=(16, 32, 64)
+ )
+
+
+# ---------------------------------------------------------------------------
+# create, and the concat options nothing else covers
+
+
+@pytest.mark.parametrize("from_file", [False, True])
+def test_create_writes_grow_linearly_in_obs_count(tmp_path, from_file):
+ """Both the generated-names path and the name-file path."""
+
+ def measure(n: int) -> int:
+ names = None
+ if from_file:
+ names = tmp_path / f"cn{n}.txt"
+ names.write_text("\n".join(f"c{i}" for i in range(n)) + "\n")
+ output = tmp_path / f"cr{from_file}{n}.h5ad"
+ with count_io() as io:
+ create_store(
+ output,
+ QUIET,
+ n_obs=None if from_file else n,
+ n_var=4,
+ obs_names=names,
+ )
+ return io.work
+
+ assert_grows_linearly(
+ measure, what=f"create (from_file={from_file})", axis="n_obs"
+ )
+
+
+@pytest.mark.parametrize(
+ "option", ["label", "index_unique"], ids=["--label", "--index-unique"]
+)
+def test_concat_option_reads_grow_linearly_in_obs_count(tmp_path, option):
+ """Both build a per-row value, so both are worth a guard of their own."""
+
+ def measure(n: int) -> int:
+ files = _inputs(tmp_path, f"co{option}{n}", n_obs=n, n_var=4)
+ kwargs = {"label": "batch"} if option == "label" else {"index_unique": "-"}
+ with count_io() as io:
+ concat_on_disk(files, tmp_path / f"oco{option}{n}.h5ad", QUIET, **kwargs)
+ return io.reads
+
+ assert_grows_linearly(measure, what=f"concat --{option}", axis="n_obs")
+
+
+def test_split_by_var_reads_grow_linearly_in_group_count(tmp_path):
+ """The var axis takes a different path through `split` than obs does."""
+ from adata.commands.split import split_store
+
+ n_var = 512
+
+ def measure(n: int) -> int:
+ source = _store_with_var_groups(tmp_path / f"sv{n}.h5ad", n_var, n)
+ with count_io() as io:
+ split_store(source, "kind", tmp_path / f"osv{n}", QUIET,
+ axis="var", manifest=False)
+ return io.reads
+
+ assert_grows_linearly(
+ measure, what="split --axis var", axis="n_groups", sizes=(8, 32, 128)
+ )
+
+
+# ---------------------------------------------------------------------------
+# fixture variants the guards above need
+
+
+def _store_with_uns_keys(path: Path, n_keys: int) -> Path:
+ obj = ad.AnnData(
+ X=np.ones((4, 2), dtype="float32"),
+ obs=pd.DataFrame(index=["a", "b", "c", "d"]),
+ var=pd.DataFrame(index=["g1", "g2"]),
+ )
+ obj.uns.update({f"k{i}": i for i in range(n_keys)})
+ obj.write_h5ad(path)
+ return path
+
+
+def _store_with_image(path: Path, side: int) -> Path:
+ obj = ad.AnnData(
+ X=np.ones((4, 2), dtype="float32"),
+ obs=pd.DataFrame(index=["a", "b", "c", "d"]),
+ var=pd.DataFrame(index=["g1", "g2"]),
+ )
+ obj.uns["picture"] = np.zeros((side, side, 3), dtype="uint8")
+ obj.write_h5ad(path)
+ return path
+
+
+def _store_with_var_groups(path: Path, n_var: int, n_groups: int) -> Path:
+ ad.AnnData(
+ X=np.ones((4, n_var), dtype="float32"),
+ obs=pd.DataFrame(index=["a", "b", "c", "d"]),
+ var=pd.DataFrame(
+ {"kind": pd.Categorical([f"k{i % n_groups}" for i in range(n_var)])},
+ index=[f"g{i}" for i in range(n_var)],
+ ),
+ ).write_h5ad(path)
+ return path
+
+
+# ---------------------------------------------------------------------------
+# streaming is bounded by the chunk, not by the input
+#
+# The README's actual claim, and the reason this tool exists. It is only
+# wholly true in one place today, so these guards say what is true rather
+# than what would be nice, and record the measured numbers so that drift is
+# visible. Everything below holds the chunk size fixed and grows the input by
+# 256x; a command that loaded everything would grow 256x with it.
+
+#: 256x span. Wide enough that "flat" and "linear" cannot be confused, and
+#: the reason these carry the `slow` marker: building a 65,536-row store is
+#: most of the ~45 s this section costs. They still gate merges; the marker
+#: is so a local run can say `-m "not slow"`.
+STREAM_SIZES = (256, 65536)
+
+
+def _peak_for(tmp_path: Path, tag: str, n: int, run) -> int:
+ source = _store(tmp_path / f"{tag}{n}.h5ad", name="s", n_obs=n, n_var=8,
+ obs_kind="numeric")
+ with count_allocations() as peak:
+ run(source, n)
+ return peak[0]
+
+
+@pytest.mark.slow
+def test_export_array_peak_memory_is_set_by_the_chunk_not_the_input(tmp_path):
+ """The closest the tool comes to the claim outright.
+
+ Measured at a fixed 1,000-element chunk: 21.5 KiB at 256 rows and
+ 34.3 KiB at 65,536 -- 1.6x for a 256x input. Not flat, so this does not
+ assert flat; the residue is fixed-size bookkeeping that grows with the
+ length of a formatted shape rather than with the data. Stating it as "at
+ least 64x better than the input" is both true and strong: loading
+ everything would be 256x.
+ """
+
+ def measure(n: int) -> int:
+ return _peak_for(
+ tmp_path, "sa", n,
+ lambda p, k: export_npy(p, "obsm/X_pca", tmp_path / f"sa{k}.npy",
+ 1_000, QUIET),
+ )
+
+ assert_grows_slower_than_input(
+ measure,
+ what="export array peak allocation at --chunk 1000",
+ axis="n_obs",
+ sizes=STREAM_SIZES,
+ at_least=64.0,
+ )
+
+
+@pytest.mark.parametrize(
+ "label,factor,run",
+ [
+ # Measured over the 256x span: export sparse 6.5x, export dataframe
+ # 2.2x, subset 46x. The factors below sit roughly midway between the
+ # measurement and linear, so ordinary variation passes and a real
+ # drift towards loading everything fails.
+ (
+ "export sparse",
+ 8.0,
+ lambda p, k, t: export_mtx(p, "X", t / f"ss{k}.mtx", None, 1_000,
+ False, QUIET),
+ ),
+ (
+ "export dataframe",
+ 8.0,
+ lambda p, k, t: export_table(p, "obs", None, t / f"st{k}.csv",
+ 1_000, None, QUIET),
+ ),
+ (
+ "subset",
+ 2.0,
+ lambda p, k, t: subset_h5ad(p, t / f"su{k}.h5ad", None, None,
+ console=QUIET, obs_query="col >= 0",
+ chunk_rows=256),
+ ),
+ ],
+ ids=["export-sparse", "export-dataframe", "subset"],
+)
+@pytest.mark.slow
+def test_streamed_peak_memory_grows_far_slower_than_the_input(
+ tmp_path, label, factor, run
+):
+ """Not flat, and the guards should not pretend otherwise.
+
+ These paths read an index or an indptr whole, so peak allocation does
+ track n_obs -- just far below it. `subset` is the weakest of the three
+ because obs columns are materialised per column; that is a known gap,
+ reported by `benchmarks/` rather than hidden here.
+ """
+
+ def measure(n: int) -> int:
+ return _peak_for(tmp_path, f"st{label[-4:]}", n,
+ lambda p, k: run(p, k, tmp_path))
+
+ assert_grows_slower_than_input(
+ measure,
+ what=f"{label} peak allocation at a fixed chunk",
+ axis="n_obs",
+ sizes=STREAM_SIZES,
+ at_least=factor,
+ )
+
+
+@pytest.mark.slow
+def test_streaming_export_sparse_costs_far_less_than_loading_the_matrix(
+ tmp_path,
+):
+ """`--in-memory` exists as the fast path; the default must earn its place.
+
+ Self-calibrating: both are measured in the same run, so the assertion
+ holds regardless of platform. Measured at 65,536 rows: 777 KiB streamed
+ against 31,763 KiB in memory, a factor of 41.
+ """
+ n = 65_536
+ source = _store(tmp_path / "cmp.h5ad", name="s", n_obs=n, n_var=8)
+
+ with count_allocations() as streamed:
+ export_mtx(source, "X", tmp_path / "streamed.mtx", None, 1_000, False, QUIET)
+ with count_allocations() as loaded:
+ export_mtx(source, "X", tmp_path / "loaded.mtx", None, 1_000, True, QUIET)
+
+ assert streamed[0] <= loaded[0] / 4, (
+ f"streaming export used {streamed[0] // 1024} KiB against "
+ f"{loaded[0] // 1024} KiB for --in-memory, a factor of only "
+ f"{loaded[0] / max(streamed[0], 1):.1f}. The default path is supposed "
+ "to be the one you reach for when the matrix does not fit."
+ )
+
+
+# ---------------------------------------------------------------------------
+# copying a store must stay inside its read budget
+#
+# `copy_dataset` sizes each read to TARGET_READ_BYTES. For variable-length
+# strings it cannot ask the dtype how wide an element is -- h5py reports the
+# itemsize of a pointer -- so it estimated. An estimate is not a bound: at
+# 4 KiB elements the 32 MiB budget became an 827 MB peak, and at 200,000 rows
+# the computed step exceeded the dataset, so the whole array was read at once.
+#
+# This is the one path the rest of this file did not reach, and it is the
+# path every `copy:` task in `subset` and every uns entry goes through.
+
+
+def _vlen_source(path: Path, width: int, n_rows: int):
+ """An HDF5 variable-length string dataset of `n_rows` x `width` bytes."""
+ import h5py
+
+ with h5py.File(path, "w") as handle:
+ handle.create_dataset(
+ "t",
+ data=np.array([("x" * width).encode()] * n_rows, dtype=object),
+ dtype=h5py.string_dtype(),
+ chunks=(1024,),
+ )
+ return path
+
+
+@pytest.mark.parametrize("width", [16, 256, 4096])
+def test_a_read_of_variable_length_strings_stays_inside_the_byte_budget(
+ tmp_path, width
+):
+ """The budget is bytes per read, so a wider element means fewer elements.
+
+ This is the exact invariant, and it costs nothing to check: the step is
+ computed, not measured. Before the fix the element width was assumed to
+ be 64 bytes, so the step was the same 524,288 elements whatever the data
+ actually held -- 2 GiB per read at 4 KiB elements, against a stated
+ 32 MiB budget.
+ """
+ import h5py
+
+ from adata.storage import (
+ TARGET_READ_BYTES,
+ _chunk_step,
+ _sample_element_bytes,
+ )
+
+ n_rows = 50_000
+ source = _vlen_source(tmp_path / f"s{width}.h5", width, n_rows)
+ with h5py.File(source, "r") as handle:
+ dataset = handle["t"]
+ sampled = _sample_element_bytes(dataset, n_rows)
+ step = _chunk_step(dataset.shape, dataset.chunks, dataset.dtype, sampled)
+
+ assert sampled >= min(width, 64), (
+ f"sampled {sampled} bytes for {width}-byte elements; the width is "
+ "being guessed rather than measured"
+ )
+ # A read may round up to a whole source chunk, hence the slack.
+ assert step * width <= TARGET_READ_BYTES * 2, (
+ f"one read would take {step * width / 1e6:.0f} MB of {width}-byte "
+ f"strings ({step} elements), against a {TARGET_READ_BYTES / 1e6:.0f} "
+ "MB budget"
+ )
+
+
+@pytest.mark.slow
+def test_copying_wide_strings_does_not_read_the_whole_array(tmp_path):
+ """And the budget holds in practice, not just in the arithmetic.
+
+ Sized so the array is several times the budget: before the fix the
+ computed step exceeded the row count, so the whole thing was read at once
+ -- 164 MB here, and 827 MB in the 200,000-row case that prompted this.
+ """
+ import h5py
+
+ from adata.storage import TARGET_READ_BYTES, copy_dataset
+
+ width, n_rows = 4096, 40_000 # ~164 MB, about 5x the budget
+ source = _vlen_source(tmp_path / "wide.h5", width, n_rows)
+
+ with h5py.File(source, "r") as src, h5py.File(tmp_path / "out.h5", "w") as dst:
+ with count_allocations() as peak:
+ copy_dataset(src["t"], dst, "t")
+
+ assert peak[0] <= TARGET_READ_BYTES * 3, (
+ f"copying a {width * n_rows / 1e6:.0f} MB array of {width}-byte "
+ f"strings peaked at {peak[0] / 1e6:.0f} MB, against a "
+ f"{TARGET_READ_BYTES / 1e6:.0f} MB read budget. A step larger than "
+ "the array means the whole array is read in one go."
+ )
+
+
+# ---------------------------------------------------------------------------
+# convert
+#
+# The streaming transpose exists because the in-memory one does not scale.
+# That is a claim about peak memory, so it is the peak that is asserted --
+# a correctness test cannot tell the two implementations apart, which is
+# exactly why they are both allowed to exist.
+
+
+def _sparse_store(path: Path, n_obs: int, n_var: int = 32, density: float = 0.2):
+ rng = np.random.default_rng(0)
+ matrix = sparse.random(
+ n_obs, n_var, density=density, format="csr", dtype="float32",
+ random_state=rng,
+ )
+ ad.AnnData(
+ X=matrix,
+ obs=pd.DataFrame(index=[f"c{i}" for i in range(n_obs)]),
+ var=pd.DataFrame(index=[f"g{i}" for i in range(n_var)]),
+ ).write_h5ad(path)
+ return path
+
+
+def _convert(source: Path, out: Path, **kwargs):
+ from adata.commands.convert import convert_store
+
+ convert_store(source, ["X"], out, QUIET, **kwargs)
+
+
+def test_convert_dtype_reads_grow_linearly_in_nonzeros(tmp_path):
+ def measure(n: int) -> int:
+ source = _sparse_store(tmp_path / f"cd{n}.h5ad", n)
+ with count_io() as io:
+ _convert(source, tmp_path / f"od{n}.h5ad", dtype="float64")
+ return io.work
+
+ assert_grows_linearly(measure, what="convert --dtype", axis="nnz")
+
+
+@pytest.mark.parametrize("layout", ["csc", "dense"])
+def test_convert_layout_reads_grow_linearly(tmp_path, layout):
+ def measure(n: int) -> int:
+ source = _sparse_store(tmp_path / f"cl{layout}{n}.h5ad", n)
+ with count_io() as io:
+ _convert(
+ source, tmp_path / f"ol{layout}{n}.h5ad", layout=layout, force=True
+ )
+ return io.work
+
+ assert_grows_linearly(measure, what=f"convert --layout {layout}", axis="n_obs")
+
+
+@pytest.mark.slow
+def test_the_streaming_transpose_does_not_hold_the_matrix(tmp_path):
+ """Peak allocation must not track nnz. This is the whole point of it.
+
+ Measured against the in-memory path in the same run, which does hold the
+ matrix and therefore does grow -- so the comparison shows the difference
+ is real rather than an artefact of how the fixture is built.
+ """
+
+ def peak(n: int, in_memory: bool) -> int:
+ source = _sparse_store(tmp_path / f"tp{in_memory}{n}.h5ad", n, n_var=64)
+ with count_allocations() as measured:
+ _convert(
+ source,
+ tmp_path / f"otp{in_memory}{n}.h5ad",
+ layout="csc",
+ in_memory=in_memory,
+ chunk=4096,
+ )
+ return measured[0]
+
+ small, large = 256, 8192
+ streaming = peak(large, False) / max(1, peak(small, False))
+ loaded = peak(large, True) / max(1, peak(small, True))
+
+ assert streaming < loaded, (
+ f"the streaming transpose grew {streaming:.1f}x over a 32x larger "
+ f"matrix and the in-memory one grew {loaded:.1f}x -- if streaming is "
+ "not the cheaper of the two it has no reason to exist"
+ )
+ assert streaming <= 8.0, (
+ f"streaming transpose peak grew {streaming:.1f}x for 32x the "
+ "nonzeros; it is supposed to be bounded by the chunk"
+ )
+
+
+def test_the_transpose_streams_unless_asked_not_to(tmp_path):
+ """The safe path is the default; --in-memory is opt-in.
+
+ Asserted by watching which function runs, because the two produce
+ identical output and no result can distinguish them.
+ """
+ import adata.core.convert as convert_module
+
+ called: List[str] = []
+ for name in ("transpose_sparse_streaming", "transpose_sparse_in_memory"):
+ original = getattr(convert_module, name)
+
+ def record(*args, _name=name, _original=original, **kwargs):
+ called.append(_name)
+ return _original(*args, **kwargs)
+
+ setattr(convert_module, name, record)
+
+ try:
+ source = _sparse_store(tmp_path / "d.h5ad", 64)
+ _convert(source, tmp_path / "default.h5ad", layout="csc")
+ assert called == ["transpose_sparse_streaming"], called
+
+ called.clear()
+ _convert(source, tmp_path / "asked.h5ad", layout="csc", in_memory=True)
+ assert called == ["transpose_sparse_in_memory"], called
+ finally:
+ for name in ("transpose_sparse_streaming", "transpose_sparse_in_memory"):
+ setattr(
+ convert_module, name, getattr(convert_module, name).__wrapped__
+ if hasattr(getattr(convert_module, name), "__wrapped__")
+ else getattr(convert_module, name)
+ )
diff --git a/uv.lock b/uv.lock
index 6c20152..dd5144a 100644
--- a/uv.lock
+++ b/uv.lock
@@ -486,7 +486,7 @@ wheels = [
[[package]]
name = "pyadata-cli"
-version = "0.5.1"
+version = "0.6.0"
source = { editable = "." }
dependencies = [
{ name = "h5py" },
]