|
3 | 3 | Notable changes to `adata-cli`. Versions are `MAJOR.MINOR.PATCH`; tags carry no |
4 | 4 | `v` prefix. |
5 | 5 |
|
| 6 | +## Unreleased |
| 7 | + |
| 8 | +### Fixed |
| 9 | + |
| 10 | +- **`concat --merge` never finished on a real store.** Aligning a var column |
| 11 | + onto the target index re-read the whole column from disk once per target |
| 12 | + variable, so the cost was quadratic: at 36,601 variables a merge that should |
| 13 | + take a fraction of a second ran for hours at 100% CPU with the output file |
| 14 | + never growing past its header. Reported against 0.5.1 (REQ-71798), where 12 |
| 15 | + of 13 pipeline tasks had to be killed after 98 minutes. The column is now |
| 16 | + read once per input, and `--merge first` / `--merge only`, which decide on |
| 17 | + presence alone, read no column values at all. |
| 18 | +- **`concat` was quadratic in the number of categories** in an obs column. |
| 19 | + Category merging probed a list rather than a dict: 2,096,128 string |
| 20 | + comparisons to union 1,024 categories, and around 5e9 for a 100k-category |
| 21 | + column. Found by the new guards. |
| 22 | +- **`split --by` was quadratic**, O(n_rows x n_groups). `group_indices` grouped |
| 23 | + rows with `np.nonzero(values == label)` inside a loop over distinct labels, |
| 24 | + rescanning each chunk once per label: at 4,096 rows, 16,384 elements scanned |
| 25 | + for 4 groups and 1,048,576 for 256. A million cells split by a thousand |
| 26 | + samples is ~10^9 comparisons. One `np.unique` pass per chunk makes it flat |
| 27 | + in the group count. Found by the new guards; order of first appearance, |
| 28 | + which names the output files, is unchanged. |
| 29 | +- **`concat` built a Python object per row** for nullable and string obs |
| 30 | + columns, then walked the list twice more. Filling a typed buffer by slice |
| 31 | + removes three full passes over every such column. |
| 32 | + |
| 33 | +### Added |
| 34 | + |
| 35 | +- **Complexity guards in the test suite** (`tests/test_performance.py`). |
| 36 | + Cost regressions now fail at merge time. They count operations rather than |
| 37 | + seconds -- h5py and zarr reads, Zarr store traffic, Python allocation and |
| 38 | + executed lines -- and assert that successive increments grow no faster than |
| 39 | + linearly, so nothing here can fail because a CI runner was busy. See |
| 40 | + [docs/TESTING.md](docs/TESTING.md#performance). Every subcommand is covered: |
| 41 | + `ls`, `view`, `create`, all five `export` and all five `import` variants, |
| 42 | + `split` on both axes, and the `concat` options nothing else reached. |
| 43 | + Two claims are now enforced rather than described -- `view` and `ls` read |
| 44 | + **zero** data elements at any store size, and streaming stays far below the |
| 45 | + input curve at a fixed `--chunk`. |
| 46 | +- **Copying variable-length strings ignored its own read budget.** The width |
| 47 | + of a vlen element was assumed to be 64 bytes, because h5py reports the |
| 48 | + itemsize of a pointer, so the step was the same 524,288 elements whatever |
| 49 | + the data held: 2 GiB per read at 4 KiB elements against a stated 32 MiB |
| 50 | + budget, and for any array shorter than that step, the whole array in one |
| 51 | + go. Copying 200,000 strings of 4 KiB peaked at 827 MB. The width is now |
| 52 | + sampled from the first 256 elements. Reported by an automated review on |
| 53 | + PR #14 and confirmed by measurement; `uns` can hold arbitrary text, so this |
| 54 | + was not a width the layer could assume. |
| 55 | +- **Peak RSS in the benchmark was floored by the runner's own memory on Linux.** |
| 56 | + A forked child inherits its parent's resident pages and `execve` folds that |
| 57 | + into the `maxrss` the kernel reports, so every contender would have measured |
| 58 | + at least what `benchmarks/run.py` used to build the fixtures — around |
| 59 | + 200 MB — and the tables would have read "everything costs about the same". |
| 60 | + Commands are now forked from a small shim: with a 330 MB parent, a no-op |
| 61 | + child goes from 326 MB to 8 MB. Caught by `test_benchmark_harness.py`, which |
| 62 | + exists for exactly this. The published figures were measured on macOS, which |
| 63 | + resets the high-water mark at exec, and are unchanged. |
| 64 | +- **A comparative benchmark** (`benchmarks/`), run on every tag against |
| 65 | + anndata and against scanpy where scanpy has a real equivalent. Reports peak |
| 66 | + RSS, wall time and output size; publishes to |
| 67 | + [docs/BENCHMARKS.md](docs/BENCHMARKS.md) and the release notes. Report-only |
| 68 | + -- it never fails a build. Fifteen cases, covering every command with a real |
| 69 | + baseline, including `h5ls -r` for `ls` and the rows where adata-cli is the |
| 70 | + slower of the two. |
| 71 | +- **`--merge drop` and `--uns-merge drop` are accepted.** `drop` was already |
| 72 | + the documented default behaviour but was rejected as a value, so a config |
| 73 | + could not state it explicitly. |
| 74 | + |
| 75 | +## 0.5.1 |
| 76 | + |
| 77 | +Makes the container image usable from Nextflow, and stops `copy_dataset` |
| 78 | +reading one row at a time from row-chunked stores. |
| 79 | + |
| 80 | +### Fixed |
| 81 | + |
| 82 | +- **Copying a row-chunked store was dominated by read latency.** The read step |
| 83 | + was the source's chunk height verbatim, so a store chunked `(1, n_cols)` was |
| 84 | + copied one row per read. On a network filesystem (Lustre, NFS) each read is a |
| 85 | + round-trip, so a million-row copy spent nearly all of its time waiting. Reads |
| 86 | + are now sized to a 32 MiB budget, rounded down to a whole number of source |
| 87 | + chunks. A `(1_000_000, 30_000)` float32 store chunked `(1, 30_000)` goes from |
| 88 | + 1 row per read to 279. |
| 89 | +- Read sizing no longer trusts `itemsize` for variable-length strings. h5py |
| 90 | + reports 8 there because the value is a pointer, which overestimated the row |
| 91 | + count by an order of magnitude and broke the memory bound. |
| 92 | + |
| 93 | +### Container |
| 94 | + |
| 95 | +- **The image could not be used from a Nextflow process.** Nextflow requires |
| 96 | + `/bin/bash` to be the container entrypoint, so `ENTRYPOINT ["adata"]` made |
| 97 | + every Docker- and Podman-backed task fail with `No such command |
| 98 | + '/bin/bash'`. Apptainer was unaffected, as `singularity exec` ignores the |
| 99 | + entrypoint. |
| 100 | +- **Task metrics were silently lost.** `procps` is absent from the base image, |
| 101 | + so Nextflow could not run `ps` to collect them. The required tool set |
| 102 | + (`bash`, `ps`, `awk`, `date`, `grep`, `sed`, `tail`, `tee`) is now installed |
| 103 | + and asserted at build time. |
| 104 | +- `PYTHONNOUSERSITE` is set, so a bind-mounted `$HOME` under Apptainer can no |
| 105 | + longer shadow the image's virtualenv with the user's `~/.local` packages. |
| 106 | +- `XDG_CACHE_HOME` points at `/tmp`, so the image tolerates being run under an |
| 107 | + arbitrary UID with no writable `$HOME`. |
| 108 | +- Added a `.dockerignore`. Local builds were copying the host's `.venv`, |
| 109 | + `.git` and `.pytest_cache` into the image. |
| 110 | + |
| 111 | +### Changed |
| 112 | + |
| 113 | +- **The image no longer sets an entrypoint, so the command must be named |
| 114 | + explicitly:** `docker run IMAGE adata view file.h5ad`, where `docker run |
| 115 | + IMAGE view file.h5ad` previously worked. |
| 116 | + |
6 | 117 | ## 0.5.0 |
7 | 118 |
|
8 | 119 | Renamed from `h5ad` to `adata-cli`, restored compatibility with current |
|
0 commit comments