Skip to content

Fix the concat --merge hang, and close the gap that hid it - #14

Open
Claptar wants to merge 16 commits into
devfrom
feat/performance-testing
Open

Claptar wants to merge 16 commits into
devfrom
feat/performance-testing

Conversation

@Claptar

@Claptar Claptar commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

Why

adata concat --merge hung for hours on a real store (REQ-71798, 0.5.1): 12 of 13 pipeline tasks killed after 98 minutes, output frozen at 1,489,960 bytes. One line re-read a whole var column from disk once per target variable.

1,029 tests missed it, and were never going to. Every fixture in the suite is at most a few hundred elements and nothing measured cost — so an entire defect class, output correct and cost wrong, had no test that could fail. For a tool whose selling point is that memory is set by chunk size rather than input size, that is the worst possible blind spot.

This PR fixes the reported bug, closes the gap that hid it, and finds two more defects of the same class on the way.

Three quadratic defects, all now guarded

Where Defect Cost Caught by
_write_var re-read the whole column per variable hours at 36,601 vars read counter
_concat_categorical not in on a list 2,096,128 comparisons at k=1,024 counting string comparisons
group_indices (split --by) rescanned each chunk per label ~10⁹ comparisons at 1M cells × 1k samples counting numpy scan operands

Each needed a different instrument. That is the main lesson here: a read counter sees the first and neither of the others.

_concat_masked also built a Python object per obs row and walked it twice more; a typed buffer filled by slice takes it from 1.21× the numeric path's executed lines to 1.00×.

Two mechanisms, deliberately separate

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 never

A test that can fail because a runner was busy does not belong in a merge gate, so the guards never touch the clock. This extends the house style already in test_commands_phase2.py rather than inventing a second one.

The guards

Counters hook h5py and zarr themselves — reads, writes, and Zarr store traffic — rather than adding a seam inside src/adata, which would only see the call sites that remembered to use it. Three invariants:

  • lineard2 <= 6·d1 on increments across n/4n/16n. The increment form cancels fixed setup cost exactly, so there is no slack constant to tune. A test asserts that calibration rather than leaving it as a comment.
  • flatview and ls read zero data elements at any store size. Not a ratio; an exact number. A control test proves the fixture had data to read, and injecting one column read into show_info fails four cases and names the culprit dataset.
  • sub-linear — streaming peak memory at fixed --chunk, held to a stated factor better than the input.

Every subcommand is covered: ls, view, create, all five export and all five import variants, split on both axes, concat including --label and --index-unique.

Reintroducing the REQ-71798 line fails the n_var guard at ratio 15.9 on all four merge strategies.

The benchmark

Fifteen cases against anndata, and scanpy where it has a real equivalent. Report-only, published to docs/BENCHMARKS.md and the release notes on every tag. Measured at 50,000 × 20,000:

Case adata-cli best baseline
concat-inner 202 MB, 2.35 s 439 MB, 16.44 s
create 78 MB, 0.28 s 2,225 MB, 2.85 s
export-sparse 68 MB, 10.79 s 759 MB, 1.90 s
ls 63 MB, 0.36 s 7 MB, 0.03 s (h5ls -r)

The last two rows are published deliberately. export sparse streams in a tenth of the memory and takes five times as long; h5ls beats us on both axes because it is C and does not start a Python interpreter. A benchmark showing only the rows we win would not be worth the runtime.

Peak RSS is measured with os.wait4, not getrusageRUSAGE_CHILDREN is a running maximum and would attribute one case's peak to every later one. Children run under a 12 GiB RLIMIT_AS so a baseline that cannot cope is a reported row rather than a dead runner.

Honest caveat

Peak memory is not flat in input size. Over a 256× span at fixed chunk it 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, because obs columns are read whole. So the guards assert what is true rather than what the README implies, and docs/BENCHMARKS.md states it plainly. Worth knowing from our own measurement rather than from a user.

Also in here

  • --merge drop / --uns-merge drop are accepted. drop was the documented default but was rejected as a value, so a config could not state it.
  • main merged in. dev was 5 commits behind and missing the whole 0.5.1 release, so this branch's CHANGELOG sat on top of 0.5.0 and pyproject.toml still said 0.5.0. Merging first means the guards are validated against main's copy_dataset rewrite, which they are.
  • .bench/, results.json and summary.md gitignored — the benchmark runner's defaults write into the repo root.

Verification

  • 904 unit tests, 92.78% coverage (floor 90%)
  • 204 compatibility tests across six anndata releases
  • All 15 benchmark cases clean at the smoke tier; calibrated once at ci
  • Wheel contents unchanged; all four workflows parse

Review notes

  • The benchmark workflow commits to main to publish results. A tag build is on a detached HEAD, so it commits as the bot with [skip ci] and rebase-retries on a race. This is the one outward-facing write added here.
  • benchmarks/ and tests/ stay out of the wheel; scanpy never enters uv.lock or the Docker image — baselines run from venvs built at benchmark time.

🤖 Generated with Claude Code

Claptar and others added 14 commits September 21, 2026 11:38
Nextflow requires /bin/bash to be the container entrypoint, so
ENTRYPOINT ["adata"] made every Docker- or Podman-backed process fail
with `No such command '/bin/bash'` -- Nextflow invokes
`docker run IMG /bin/bash -ue .command.sh`. Apptainer users were
unaffected, since `singularity exec` ignores the entrypoint, which is
probably why this went unnoticed. Drop the entrypoint and spell the
command out in CMD instead.

Nextflow also needs bash, ps, awk, date, grep, sed, tail and tee in the
task container to collect metrics. procps is not in bookworm-slim, so
every task silently lost its trace row. Install it, and assert the whole
set at build time so base-image drift fails the build rather than every
task.

Two further fixes for bind-mounted runtimes:

- PYTHONNOUSERSITE, because Apptainer bind-mounts the host $HOME and a
  user's ~/.local site-packages would otherwise shadow the venv.
- XDG_CACHE_HOME, because Nextflow is commonly configured with
  `-u $(id -u):$(id -g)`, leaving no writable $HOME.

Add the missing .dockerignore. Without one, `COPY . .` pulled the host's
.venv, .git, .pytest_cache and .claude/worktrees (two full repo copies)
into every local build; CI never hit this because a fresh checkout has
none of them. Also move the apt and duckdb layers ahead of `COPY . .`,
so a source edit no longer re-downloads duckdb.

Verified against Nextflow 26.04.6: the pipeline completes and the trace
is populated (%cpu=296.5%, peak_rss=11.2 MB).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A container-only release: the Python package is unchanged. Tagging it is
what republishes the image, since .github/workflows/quay-on-tag.yml only
builds on a tag push and the 0.5.0 tag must not be moved -- dropping the
entrypoint is a breaking change for anyone pinned to it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_chunk_step returned the source's chunk height verbatim, so a store
chunked (1, n_cols) was copied one row per read. On a local disk that is
merely wasteful; on Lustre or NFS every read is a round-trip costing
milliseconds, so a million-row copy spent nearly all of its time
waiting. Reads are now grown to a 32 MiB budget and rounded down to a
whole number of source chunks, since a partial read still decompresses
the whole chunk. A (1_000_000, 30_000) float32 store chunked
(1, 30_000) goes from 1 row per read to 279.

Sizing needs the dtype, which h5py misreports for variable-length
strings: itemsize is 8 there because the value is a pointer, not the
text. Assume VLEN_ELEMENT_BYTES instead, so the row count is not
overestimated by an order of magnitude and the memory bound holds.

The step is floored at one whole chunk, which makes the budget a target
rather than a cap for a source whose own chunk already exceeds it. That
matches the previous behaviour and is now commented as such.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Setting XDG_CACHE_HOME was self-defeating as written: uv honours it, so
`uv sync` created /tmp/.cache root-owned and mode 0755 during the build.
A task running under `-u $(id -u):$(id -g)` then could not write to the
very path the image advertises as its cache, which is worse than leaving
the variable unset.

Clear the directory and recreate it world-writable in the same layer as
the sync. Verified: `mkdir $XDG_CACHE_HOME/probe` now succeeds as an
arbitrary UID, where it failed with EACCES before.

Reported by Codex review on #12.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nextflow-compatible image, faster streaming reads, release 0.5.1
…ariable

`_write_var` aligned each candidate var column onto the target index with
`tuple(read_str_all(group[name])[i] for i in where)`. Only the outermost
iterable of a generator expression is evaluated eagerly, so `read_str_all`
ran once per target variable -- a full read of the column from disk, per
element of the same column.

The cost is quadratic in the number of variables. Measured here on two
synthetic inputs with two var columns: 0.48 s at 500 vars, 1.40 s at 1,000,
4.55 s at 2,000, 16.57 s at 4,000. Extrapolated to the 36,601 vars of
REQ-71798 that is tens of minutes to hours of pure CPU with the output file
never growing past the header it wrote first, which is exactly what was
reported against 0.5.1: 12 of 13 pipeline tasks killed after 98 minutes, and
byte counts identical between `--merge same` and `--merge first`.

Reading the column once makes the same case 0.02 s, and 0.25 s at the full
36,601 x 8,766 of the ticket. `first` and `only` decide on presence alone and
now read no column values at all, which is why their cost matched `same`
before.

Also accept `--merge drop` / `--uns-merge drop`. `drop` is the documented
default behaviour but was rejected as a value, so a config could not state it.

The three new tests count full-column reads rather than timing the merge: the
defect is a complexity bug, invisible to every existing concat test because
they all use two or three variables, and a wall-clock assertion would be flaky
on shared CI. `test_concat_merge_same_reads_each_var_column_once_per_input` is
parametrised over 4 and 64 variables so that a cost which grows with the var
count fails the second case; against the old code it reports 24 and 384 reads
where 6 are expected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The --merge hang was not a one-off bug but a defect class the suite could not
see: output correct, cost wrong. Every fixture here is a few hundred elements
and nothing measured cost, so no test could have failed.

tests/perf_counters.py instruments four seams, all verified against the pinned
h5py 3.15.1 and zarr 3.1.5: h5py.Dataset.__getitem__, zarr.Array.__getitem__,
and LocalStore.get/set/delete (coroutines, wrapped as such). Patching the
libraries rather than adding a seam inside src/adata is deliberate -- an
in-repo helper would only see the call sites that remembered to use it, and
the matrix paths in subset.py and concat.py slice the backend objects
directly. Elements are counted, not just calls, so a vectorised-but-quadratic
read is caught too. The known bypasses (read_direct, np.asarray(dataset),
asstr) are documented, and two canary tests fail loudly if a hook stops
firing, since otherwise every ratio below would pass on zeros.

The invariant compares successive increments rather than raw counts:

    d1 = c(4n) - c(n);  d2 = c(16n) - c(4n);  assert d2 <= 6 * d1

The increment form cancels any fixed setup cost exactly, so there is no slack
constant to tune and no floor for a small-coefficient quadratic to hide under.
At 4x spacing the ratio is 4.0 for linear work, 4.4 for n log n, 8 for n**1.5
and 16 for quadratic, so 6 sits in the gap with room either side; a test
asserts that calibration rather than leaving it as a comment. Each guard
scales exactly one axis -- n_var, n_obs, n_inputs, n_columns, n_categories --
because scaling two at once makes legitimate work look quadratic.

Reintroducing the REQ-71798 line makes the n_var guard fail at ratio 15.9 on
all four merge strategies.

Writing the guards turned up two more defects of the same class, both fixed
here:

_concat_categorical unioned categories with `if category not in categories`
on a list -- O(k^2). Neither instrument above sees it: the category lists are
read once either way, and `x not in lst` is a single bytecode, so the
quadratic lives inside C-level list membership. Counting string comparisons
via a str subclass is what makes it visible: 2,096,128 comparisons at k=1024,
and around 5e9 for a 100k-category obs column. A dict takes it to 0.

_concat_masked filled a Python list of length n_obs one element at a time and
then walked it twice more. A typed numpy buffer filled by slice takes it from
1.21x the executed Python lines of the numeric path to 1.00x. The string path
is 1.35x and stays there -- read_str_all materialises Python str objects,
which is inherent to reading strings rather than a per-row loop -- so its
guard is set at that measured level with the reason recorded.

Also registers the perf marker (--strict-markers is on) and notes what these
tests deliberately do not claim: obs columns are read whole, so peak
allocation is O(n_obs) and not O(chunk). The streaming guarantee holds for X,
not for obs annotation. benchmarks/ will report that curve.

853 tests pass, coverage 92.6%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Complements the complexity guards rather than duplicating them. The guards
count operations, are deterministic, and gate merges; this measures wall time
and peak RSS at realistic size, runs on tags, and never fails a build --
timing on a shared runner is too noisy to gate a release on, and a benchmark
that can block a publish stops being run.

Peak RSS is the headline. adata-cli exists so that memory is set by --chunk
rather than input size, and for data that fits in RAM loading the whole thing
is frequently faster; a table reporting only wall time would misrepresent the
tool in the direction of flattery and then in the direction of failure.

Measurement (benchmarks/_measure.py):

os.wait4, not resource.getrusage. RUSAGE_CHILDREN is a running maximum over
every child a process has reaped, so a 400 MB case followed by a 1 kB one
reports 400 MB twice and every later row inherits the largest earlier peak.
Output goes to temporary files rather than pipes, because communicate() reaps
the child and there is then nothing for wait4 to report. ru_maxrss is
normalised -- KiB on Linux, bytes on macOS.

RLIMIT_AS at 12 GiB on every child. Cases the in-memory baseline cannot
survive are the point of the comparison, but an uncontained OOM kills the
runner agent and the job ends with no report at all; with a ceiling it is a
row that says "out of memory" at a limit we can state. A timeout records the
output size at the kill, which is how the 0.5.1 hang actually presented --
1,489,960 bytes, never growing -- and distinguishes it from slow progress.

tests/test_benchmark_harness.py covers exactly this and nothing else. If peak
RSS were attributed to the wrong process, every published table would be
wrong and would still look plausible.

Fairness rules, written into cases.py and docs/TESTING.md because this is
what decays first: use the best idiom the baseline has (read_elem, backed
mode) and never a strawman full load as the primary row; pin compression on
both sides, since adata-cli forwards the source's settings while write_h5ad
defaults to none; print n/a with a reason where scanpy or concat_on_disk has
no equivalent, because an omitted row reads as an oversight; include a
startup floor, as the CLI costs 0.3-1 s to import and scanpy 3-8 s; say
whether the page cache was dropped; and leave the rows where anndata wins
exactly as measured. _concat_csr loops per row in Python and scipy's C vstack
will often beat it on time at several times the memory -- that trade is the
argument for this tool, and hiding it would make the table worthless.

Baselines run from venvs built up front rather than `uv run --with`. The
latter is right for reference_stores.py, where fixture cost is irrelevant,
and wrong here: the first invocation would put hundreds of megabytes of wheel
downloads into the measured wall time and uv's own memory into the measured
peak. scanpy stays out of uv.lock and out of the image either way.

Publishing goes to docs, not an orphan branch: docs/benchmarks/<tag>.json for
the series and docs/BENCHMARKS.md for the rendered page, both already served
by Pages from docs/, plus the step summary and a best-effort idempotent
append to the release notes. Artifacts expire in 90 days and one absolute
number with nothing to compare against says very little, so the durable
series is the part that matters. Note for review: the docs commit is the one
outward-facing write here -- a tag build is on a detached HEAD, so the job
commits to main as the bot with [skip ci] and rebase-retries on a race.

Its own workflow 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. It benchmarks the checked-out source, not the published wheel, which
would mean waiting on publish-pypi and then on index propagation for a
measurement that comes out the same.

861 tests pass, coverage 92.6%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The ci tier is much cheaper than the plan assumed: 580 MB of fixtures and
three cases ran end to end in 43 seconds, so the full set is minutes rather
than the hour the workflow allows. The 90-minute timeout stays as headroom
for the large tier; calling it an estimate would have been wrong.

Records the numbers that run produced, including that adata-cli's 202 MB
peak is not flat in input size -- obs columns are read whole and a dense
block is --chunk x n_var. Better to state that next to the figures than to
let the page imply a guarantee the code does not yet meet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…g it

The previous round pointed both mechanisms almost entirely at concat. ls,
view, create, the five exports and the five imports had no cost coverage at
all. Extending to them needed three additions to the instruments, and turned
up a third defect of the same class as the first two.

split --by was O(n_rows x n_groups). core/select.py group_indices grouped
rows with `np.nonzero(values == label)` inside a loop over distinct labels,
so each chunk was rescanned once per label: measured at 4,096 rows, 16,384
elements scanned for 4 groups and 1,048,576 for 256 -- exactly n_rows per
group. A million cells split by a thousand samples is 10^9 comparisons, and
split would have looked like a hang for the same reason concat --merge did.
np.unique with return_index and return_inverse does it in one pass per chunk:
16,388 elements at 4 groups and 16,640 at 256, flat to within 1.5%. Order of
first appearance is preserved through argsort on the first-occurrence
indices, because it names the output files.

The existing split guard passed throughout. The chunk is already in memory,
so no read counter moves -- the same blind spot as _concat_categorical, and
the same lesson: one instrument is never enough.

Three additions to tests/perf_counters.py:

count_scanned_elements counts what is handed to numpy's scanning primitives.
It is a floor, not a measurement -- an operator like `values == label`
dispatches to the ufunc in C and never passes the patched np.equal -- so the
guard using it asserts a lower bound, and the docstring says exactly what is
and is not visible.

HDF5 writes were not counted at all, which made every import and create guard
silently vacuous at zero. Both Dataset.__setitem__ and Group.create_dataset
are now hooked; the latter matters because create_dataset(name, data=...)
writes its payload at creation and never touches __setitem__.

assert_independent_of asserts cost does not grow at all, and
assert_grows_slower_than_input asserts it grows by at least some factor less
than the input. assert_grows_linearly could only catch super-linear growth,
and would have accepted a 64x increase in a command that is supposed to read
nothing.

The vacuity floor in assert_grows_linearly moved from `d1 >= mid` to half the
increment: work that is exactly one operation per element -- export dict
reads each key once -- gives 0.75 * mid and was being rejected as
unmeasurable.

Two claims are now enforced rather than asserted in prose:

view and ls read zero data elements, at 64 rows and at 4,096. Not "grows
slowly" -- zero, an exact count needing no tolerance. A control test exports
the same fixture to prove there was data there to read, so the zero cannot
pass by accident, and injecting a single column read into show_info fails
four of the ten cases with the offending dataset named.

Streaming is bounded well below the input, which is weaker than the README
implies and is what the measurements support. Over a 256x span at fixed
chunk: 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 asserted as such, and
subset's guard is deliberately the loosest -- obs columns are materialised
per column, a known gap that benchmarks/ reports rather than this hiding.
Streamed export sparse is also asserted to stay under a quarter of what
--in-memory costs, measured in the same run so the factor holds anywhere.

Coverage added for view, view --types, ls, ls --long, ls --plain, create
(generated names and name file), export dataframe by rows and by columns,
export array, export sparse both paths, export dict, export image, import
dataframe/array/sparse/dict/image, concat --label, concat --index-unique and
split --axis var.

895 tests pass, coverage 92.71%. The perf file is ~55 s, most of it building
65,536-row fixtures, so those three carry the previously unused slow marker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six cases added, chosen by whether a real baseline exists: ls, create,
export-array, export-sparse, import-dataframe and concat-outer. export image,
export dict, import image and import dict are deliberately left out -- no
library offers them, so the rows would only ever read n/a while adding
runtime to every tag. The complexity guards cover them instead.

Two of the new rows are the reason the report is framed as "peak RSS against
wall time" rather than as a leaderboard. At the ci tier, export sparse
streams a 50,000 x 20,000 matrix in 68 MB and takes 10.8 s where loading it
whole takes 759 MB and 1.9 s; and h5ls -r lists the file in 0.03 s and 7 MB
against our 0.36 s and 63 MB, being C rather than a Python process that has
to import typer, rich, h5py and zarr first. Both are published. A benchmark
that showed only the rows we win would not be worth the runtime.

The rest of the ci run: create 78 MB / 0.28 s against 2,225 MB / 2.85 s,
import-dataframe 107 MB / 0.41 s against 571 MB / 1.84 s, concat-outer
202 MB / 2.60 s against 1,362 MB / 4.02 s in memory and 436 MB / 2.24 s for
concat_on_disk, which is slightly the faster of the two.

Harness changes the new cases needed:

Baseline environments now install dask. concat_on_disk imports it to
concatenate a dense element and raises ModuleNotFoundError without it, which
surfaced as soon as the fixtures gained an obsm. Giving the baseline its best
idiom is the standing rule; measuring a library crippled by a missing
optional dependency would be measuring our own setup.

A case can declare a sidecar input, built once from the real store, so
import-dataframe reads a CSV the file could plausibly have held rather than
an invented one. Contenders whose binary is absent -- h5ls is often not
installed -- are recorded as n/a with the reason rather than crashing the
run.

create takes its shape from the tier. Hardcoding 50,000 x 20,000 made the
smoke tier allocate a 4 GB dense array, so "smoke" was not smoke: its peak
went from 2,324 MB to 64 MB once the shape followed the tier.

Fixtures gained a 50-column obsm, without which export array and import array
had nothing of realistic width to move.

docs/TESTING.md gets the three invariants and when each applies, the two
write seams and why both are needed, count_scanned_elements and the precise
statement of what it cannot see, and a per-command coverage table so the next
command's author knows what is expected. The measured streaming growth
figures are recorded there too -- 1.6x, 2.2x, 6.5x, 46x over a 256x span --
because the guards are set from them.

895 tests pass, coverage 92.71%. All 15 benchmark cases run clean at smoke.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three problems with docs/BENCHMARKS.md, all of which would have shown up
first on the next tag.

Nothing linked to it. Neither README.md nor docs/index.md mentioned the page
at all, so the one document that says what the streaming claim actually costs
was reachable only by guessing the URL. Both now link it, and the README's
"streaming access to very large stores" bullet points straight at it --
including at the rows where loading the file outright is faster.

`publish()` overwrote the whole page with bare tables. Every word explaining
what the numbers mean would have been deleted the first time the workflow
ran. The prose now lives in benchmarks/page_template.md with a `<!-- results
-->` marker, `build_page` fills it, and docs/BENCHMARKS.md is generated from
that same template so the words exist once. A test asserts the marker is
still there and that the framing and the trailing sections survive a
republish.

The rendered results opened with their own H1 and repeated the "peak RSS is
the headline" paragraph the template already carries. Results are now an H2
under the page's own title, per-case tables are H3, and the duplicated
framing is gone -- the published page has exactly one H1, which the test
checks.

The page itself now says what is measured, which four commands are
deliberately absent and why, and carries the caveat that peak memory is not
flat in input size: 1.6x to 46x over a 256x span depending on the command.
Better for that to be on the page than discovered by a user.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fallout from resolving the CHANGELOG merge by dropping conflict markers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 23, 2026 20:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 23, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-23T20:36:12.983234Z 7c38c41 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions

github-actions Bot commented Sep 23, 2026

Copy link
Copy Markdown

Test Results (py3.13)

910 tests  +85   910 ✅ +85   3m 59s ⏱️ + 2m 48s
  1 suites ± 0     0 💤 ± 0 
  1 files   ± 0     0 ❌ ± 0 

Results for commit e8c24fb. ± Comparison against base commit 7f4df63.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 23, 2026

Copy link
Copy Markdown

Test Results (py3.12)

910 tests  +85   910 ✅ +85   4m 4s ⏱️ + 2m 41s
  1 suites ± 0     0 💤 ± 0 
  1 files   ± 0     0 ❌ ± 0 

Results for commit e8c24fb. ± Comparison against base commit 7f4df63.

♻️ This comment has been updated with latest results.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7c38c41187

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/adata/storage/__init__.py Outdated
Comment on lines +469 to +470
if itemsize <= 0 or getattr(dtype, "kind", None) in ("O", "T"):
itemsize = VLEN_ELEMENT_BYTES

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bound reads for variable-length string datasets

When copy_dataset encounters a variable-length string array, this fixed 64-byte estimate makes a one-dimensional dataset use chunks of up to 524,288 elements, regardless of their actual lengths. Because copy_tree also copies arbitrary arrays under locations such as uns, entries can legitimately be much larger than cell or gene names; for example, 1 KiB strings make one read allocate roughly 512 MiB, while larger text can exhaust memory entirely. This regresses the previous source-chunk-sized streaming behavior and defeats the stated memory bound, so variable-length arrays need a conservative row cap or sizing based on stored payload size.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and worse than described — fixed in e8c24fb.

Measured on 200,000 vlen strings, varying only the element width:

element width peak allocation
16 B 11.5 MB
256 B 59.4 MB
1 KiB 213.0 MB
4 KiB 827.4 MB

against a stated 32 MiB budget. The extra detail is that the computed step (524,288 elements) exceeded the row count in all four cases, so the whole array was read in one go — the opposite of what the budget exists for.

The width is now sampled from the first 256 elements rather than assumed; VLEN_ELEMENT_BYTES remains the fallback and a floor. Two guards added in tests/test_performance.py: one checking the arithmetic directly (step × width within budget at 16/256/4096 B), one checking it in practice on a 164 MB array — the latter fails against the previous code at 165 MB where the bound is 100 MB.

You were right that uns is the problem case: cell and gene names really are under 64 bytes, but copy_tree carries arbitrary text, so it was never a width this layer could assume.

Claptar and others added 2 commits September 23, 2026 21:51
…nner's

CI failed one test on both interpreters, and it was not a flaky threshold.
test_peak_rss_is_attributed_to_the_right_child reported 146 MB for a child
that allocated 1 KB, after a 410 MB child. Its docstring says why it exists:
if peak RSS were attributed to the wrong process, every table this project
publishes would be wrong and the numbers would still look plausible.

A uniform interpreter floor was ruled out by the data -- at 140 MB the big
child would have measured 540 MB, not 410. Reproduced 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
    parent 329.6 MB  ->  via shim       8.1 MB

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. A child of a fat parent cannot appear small. macOS resets it at
exec, which is why this passed locally and failed on CI -- the one platform
the benchmark actually runs on.

This was never only a test problem. run.py imports anndata, pandas and numpy
to build fixtures in the same process that calls measure(), so on the runner
every contender would have been floored at roughly 200 MB. The headline
result -- 202 MB against 1,847 MB for ad.concat -- would have collapsed to
"everything costs about the same", which is precisely the claim the benchmark
exists to test, failing silently in the flattering direction.

measure() now re-invokes benchmarks/_measure.py as a subprocess and that
freshly-exec'd interpreter, about 8 MB, forks the command being measured. The
in-process logic is unchanged, renamed _measure_here; main() grew the
--timeout, --memory-limit, --cwd and --env options it needs to carry the
call. RLIMIT_AS still applies via the shim's preexec_fn, so OOM containment
for the large tier is intact.

Dropping preexec_fn to get posix_spawn was the obvious alternative and does
not work: the middle row above measures 329.5 MB that way too. It would also
have given up the address-space ceiling.

The test now asserts the property rather than a ratio. It holds 300 MB of
ballast for its duration, measures a no-op baseline child, and requires both
that the baseline is small in absolute terms -- the absence of an inherited
floor -- and that the small child resembles the baseline rather than the big
one before it. Against the old code on Linux it fails at 342 MB for a no-op
child; the previous form only failed when the floor happened to exceed a
quarter of the largest child.

Two things found on the way:

build_environments only checked that the interpreter existed, so a reused
--work directory kept whatever was installed first. Adding dask to
ENVIRONMENTS had no effect on an existing tree and concat_on_disk went on
raising ModuleNotFoundError as though that were a finding about anndata. The
package list is now recorded beside the venv and triggers a rebuild when it
changes, with --clear so the rebuild does not die on the existing tree.

A command that does not exist is classified n/a by the shim with the reason,
rather than surfacing as a traceback; the duplicate check in run.py is gone.
The refuse-to-overwrite guard stays in the parent so it still raises.

Re-measured at the ci tier afterwards: peak RSS is unchanged to within a
megabyte across every documented case, as expected since those figures came
from macOS. They stand.

905 tests pass, coverage 92.78%. All 15 benchmark cases clean at smoke; the
harness tests pass under python:3.12-slim, where they now also cover the
RLIMIT_AS path that macOS skips.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Flagged by an automated review on PR #14, on code that came in with the main
merge -- 4859445, released in 0.5.1. Confirmed by measurement, and worse than
the review described.

_row_bytes assumed 64 bytes for a variable-length element, because h5py
reports the itemsize of a pointer. An assumption is not a bound. The step was
therefore the same 524,288 elements whatever the data actually held:

    16 B strings, 200k rows ->  11.5 MB peak
   256 B                    ->  59.4 MB
     1 KiB                  -> 213.0 MB
     4 KiB                  -> 827.4 MB     against a stated 32 MiB budget

and because the computed step exceeded the row count in every one of those
cases, the whole array was read in a single go -- the opposite of what the
budget is for. Real cell and gene names do sit under 64 bytes, but copy_tree
carries arbitrary `uns` content, so this is not a width the storage layer can
assume.

The width is now sampled from the first 256 elements, one small read against
a copy about to stream the whole array. VLEN_ELEMENT_BYTES stays as the
fallback when sampling is not possible, and as a floor so a column of empty
strings cannot produce an unbounded step. _row_bytes keeps its old two-
argument form via a default, so the existing 0.5.1 tests still describe it.

Two guards, because they catch different things. The parametrised one checks
the arithmetic directly -- step x width must stay inside the budget at 16,
256 and 4096 bytes -- which is exact and costs nothing. The slow one checks
it in practice on a 164 MB array, and is the one that fails against the old
code, at 165 MB where the bound is 100 MB.

This is the path the rest of test_performance.py did not reach, and every
`copy:` task in subset and every uns entry goes through it. The coverage
claimed in the previous commit was not as complete as it read.

909 tests pass, coverage 92.79%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants