Skip to content

Assert memory ceilings with pytest-memray, and drop the benchmark memory metrics - #56

Merged
aarmey merged 2 commits into
mainfrom
infra/pytest-memray
Sep 18, 2026
Merged

aarmey merged 2 commits into
mainfrom
infra/pytest-memray

Conversation

@fishidaho

Copy link
Copy Markdown
Contributor

Problem

Two mechanisms measured memory, and neither said quite what was meant.

The tests reset tracemalloc's peak around an operation and compared the delta to a hand-computed bound. The bound was rebuilt inline at each site, setup had to be fenced off by hand, and the number moved with the runner: the accumulator being bounded is nthreads * n_minor * width * 8 bytes, so the same assertion meant something different on a 4-core runner than on a 96-core one.

The benchmark suite recorded the same quantity across ~35 cases with the same portability problem, against ceilings that were already stale — generous figures taken before the memory fixes landed, with a README table tracking how far off they were.

Fix

pytest-memray ceilings in tests/, which is what anndata uses for this. It intercepts the allocator rather than Python's allocation hooks, and is enforced by having the plugin installed — no --memray flag, so CI needs no change.

Three conventions keep the ceilings meaningful:

  • pinned_threads (new, in conftest.py) fixes numba's thread count, so the expected allocation is a property of the code and not the machine.
  • Inputs and JIT warm-up live in module-scoped fixtures. A mark measures the test body only, so the array under test no longer has to be fenced off.
  • Ceilings ride on each pytest.param, never request.applymarker — the plugin reads markers at collection time, so one applied from the body is silently ignored and the test asserts nothing.

The benchmark suite no longer measures memory. Each retired case has a counterpart:

retired case now asserted by
minor_sum_peak_mb test_minor_axis_reductions_allocate_nothing_nnz_sized[sum]
minor_extrema_peak_mb ...[max]
minor_getnnz_peak_mb ...[getnnz]
minor_selection_peak_mb test_minor_axis_selection_allocates_no_nnz_sized_scratch
misaligned_matmul_peak_mb test_misaligned_matmul_peak_is_bounded_by_the_accumulator_budget
peak_alloc_mb_view (30 cases) the same test, parametrized over every recipe

Layout size and throughput stay: bytes_per_nonzero is the format's core claim and has no other guard, and the scipy-relative timings have no test equivalent. 24 gated cases remain.

Measurements

Allocations at 4 pinned threads, all two orders of magnitude under nnz-scale:

operation allocates nnz-scale for the same input
minor-axis sum / getnnz 20 KB 8 MB
minor-axis max 40 KB 8 MB
misaligned matmul 132 KB 3.8 MB

Every ceiling was checked by tightening it to 1 KB and confirming it fails. The limit_leaks test was checked against a deliberately retained result.

Selection gets a ceiling above its output (2.3 MB — a selection's result legitimately grows with what was selected) and below output-plus-an-nnz-sized-temporary.

Neither tool sees RSS, so neither catches allocator fragmentation — the failure behind #49's 140 GB. That is documented in benchmarks/README.md as diagnosable but not reliably gateable.

Merge order

After #55, which it conflicts with in benchmarks/: the cases #55 adds carry peak_alloc_mb keys this PR removes. Landing second means dropping those keys.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Rvu3cf8ZL7F5EPX22eo6Je

The two memory assertions in the suite reset `tracemalloc`'s peak around the
operation and compared the delta to a hand-computed bound. That works, but
the bound was rebuilt inline at each site, the setup had to be fenced off by
hand, and the number it compared against moved with the runner's core count:
the accumulator is `nthreads * n_minor * width * 8` bytes, so the same
assertion meant something different on a 4-core runner than on a 96-core one.

`pytest-memray` states the same property as a mark. It is what anndata uses
for this, it intercepts the allocator rather than Python's allocation hooks,
and a ceiling is enforced by having the plugin installed -- no `--memray`
flag, so CI needs no change.

Three conventions keep the ceilings meaningful:

  - `pinned_threads` (new, in conftest) fixes numba's thread count, so the
    expected allocation is a property of the code and not of the machine.
    Measured at 4 threads: 20 KB for a minor-axis sum, 40 KB for the extrema
    kernels, 132 KB for the misaligned matmul.
  - Inputs and JIT warm-up move into module-scoped fixtures. A mark measures
    the test body only -- verified -- so the array under test no longer has
    to be fenced off by resetting a counter.
  - The ceiling rides on each `pytest.param`, never on `request.applymarker`.
    The plugin reads the marker at collection time, so a marker applied from
    the body is silently ignored and the test asserts nothing. Every ceiling
    here was checked by tightening it to 1 KB and confirming it fails.

Adds one `limit_leaks` test: a cache that grows with use is invisible to a
ceiling, since each pass alone stays well under it, and that is the shape of
the bug d286cf3 fixed. Confirmed it catches a deliberately retained result.

`benchmarks/` keeps `tracemalloc`: it runs outside pytest, where marks do not
apply, and it records numbers rather than bounding them. Both READMEs now say
which tool owns which question, including that neither sees RSS -- so neither
catches the allocator fragmentation behind #49's 140 GB, which is diagnosable
but not reliably gateable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@fishidaho
fishidaho requested a review from aarmey September 18, 2026 03:38
With `pytest-memray` asserting the memory properties directly, recording them
here as well left two mechanisms for one claim, and the weaker one gated.

The claims were never about a number. "A minor-axis reduction must not
allocate anything nnz-sized" either holds or it does not; a recorded figure
shows it drifting only after the fact, against a baseline that has to be
re-recorded whenever the bound legitimately moves -- and the checked-in
ceilings were already stale, generous figures taken before the memory fixes
landed, with a table in the README tracking how far off they were. Worse, the
number was never portable: `peak_alloc_mb` measured a thread-local
accumulator sized by the runner's core count, so the same code produced a
different figure on every machine. A ceiling with a pinned thread count says
the thing that is actually meant.

Removed: the five memory-only cases, `peak_alloc_mb_view` and its companions
from the 30 normalized-view cases, `matmul_peak_alloc_mb` from the slow case,
and `peak_alloc_mb` from the harness. 24 gated cases remain.

Nothing loses coverage. Two gaps were filled first rather than dropped:
`minor_selection_peak_mb` had no test counterpart, so
`test_minor_axis_selection_allocates_no_nnz_sized_scratch` covers it -- with a
ceiling above the output, since a selection's result legitimately grows with
what was selected, and below output-plus-an-nnz-sized-temporary. And the 30
per-recipe view cases became a parametrization of the existing matmul ceiling
over every recipe; measured, all five allocate the same 132 KB.

Layout size and throughput stay. `bytes_per_nonzero` is the compression
format's core claim and has no other guard, and the scipy-relative timings
have no equivalent in the test suite. Only the half memray replaces is gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@fishidaho

Copy link
Copy Markdown
Contributor Author

Removed the limit_leaks test. It ran in 0.06s when I added it and verified it against a deliberately retained result, but on a 48-thread machine it now hangs indefinitely — the rest of that file completes in 1.4s. A test that is sometimes instant and sometimes never returns cannot go in CI, and it would have hung developers rather than the runner, since CI's lower core count got through it.

The limit_memory ceilings are unaffected and remain the substance of this PR. Cache-growth-with-use is no longer covered by a test; noted here rather than papered over.

Docstrings and comments trimmed throughout to say what the code does rather than how it got here.

@aarmey
aarmey merged commit c6b4a84 into main Sep 18, 2026
6 checks passed
@aarmey
aarmey deleted the infra/pytest-memray branch September 18, 2026 13:44
aarmey added a commit that referenced this pull request Sep 18, 2026
- benchmarks/cases.py: misaligned_matmat_vs_scipy/misaligned_rmatmat_vs_scipy
  still called peak_alloc_mb() after #56 dropped that helper entirely from
  benchmarks/harness.py, so every benchmark run failed with NameError. This
  was already broken on main (confirmed: main's own CI is red for the same
  reason), not something introduced by this branch, but it blocks this PR's
  gate too. Drops the two dead peak_alloc_mb entries (case output and
  baselines.json), matching how #56 already removed the other five
  memory-metric cases.
- _ivcsc.py/_rapid_load.py: the shared _decode_varint helper from an earlier
  commit here expects a np.int64 position, but both callers seeded `pos` as
  a plain `0`, which `ty check` flagged as a real (if numba-tolerated) type
  mismatch. Seed `pos = np.int64(0)` instead.
- _rapid_load.py: the _read_ivcsr_group extraction from the same earlier
  commit gives `kwargs` an explicit `dict[str, Any]` return type, which
  resolves the invalid-argument-type ty previously needed ignore comments
  for on `ad.AnnData(**kwargs)` -- removed the two now-unused ignores.
- ruff format on the three files touched by the complexity refactor.

uv run pytest: 1352 passed, 61 skipped. uv run python -m benchmarks.run
--set fast: no regressions. ruff check/format, ty check, codespell all pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wKWZn2btQ3Uo4grjuxxd2
aarmey added a commit that referenced this pull request Sep 19, 2026
* Add CLAUDE.md maintainability-check guidance and fix flagged code smells

Adapted from cccRISE's CLAUDE.md: adds vulture/radon/xenon to the dev
dependency group and documents how to run dead-code, duplication, and
cyclomatic-complexity checks against src/vsparse/.

Running those checks surfaced and fixed:
- _base.py: removed _VCSBase._major_range, a dead method with no callers
  anywhere in the repo (including tests).
- _ivcsc.py/_rapid_load.py: extracted the repeated varint-decode loop into
  a shared _decode_varint njit helper, reused across _unpack,
  _decode_chunks, and _decode_selected_rows.
- _anndata_class.py: extracted the duplicated X/raw_X coercion logic in
  VCSCAnnData's setters into a shared _coerce_vcs helper.

The remaining jscpd-flagged duplication is the major=row/major=col pair of
numba kernels repeated throughout _norm_common.py, _vcs_matmul.py, and
_ops.py -- an intentional, documented pattern for cache-friendly parallel
iteration over each array's own memory layout, not a code smell.

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

* Break up the remaining C-rank functions flagged by radon/xenon

Pulls apart every plain-Python function radon/xenon ranked C (cyclomatic
complexity >= 11), all in _anndata_class.py/_norm_common.py/_rapid_load.py,
by extracting cohesive pieces into named helpers:

- _anndata_class.py: __getitem__/copy/from_anndata/to_anndata/_write_group
  shared a repeated "filter the None key, transform each value" comprehension
  -- factored into _filtered/_map_subset_1d/_map_subset_2d/_map_copy.
  normalized()'s "reuse stats stored in obs/varm/uns" branch is now
  _normalized_from_stored(). X/raw_X's setter coercion was already shared
  via _coerce_vcs (see previous commit).
- _norm_common.py: NormalizedViewBase.__init__'s VCSC/VCSR statistics
  dispatch and mean/scale finishing are now _column_gstats/_column_mean_scale.
- _rapid_load.py: load_and_normalize's obs_filter/no-filter branches (each
  with their own gene-mask logic) are now _rows_without_obs_filter/
  _rows_with_obs_filter/_compute_gene_mask; the h5py group read and the
  min_cells validation are now _read_ivcsr_group/_validate_min_cells.

All of these were plain Python orchestration -- no behavior changes, and no
numba kernels touched. The one function still ranked C,
_column_stats_major_is_col in _norm_common.py, is a numba-parallel kernel
whose branchiness is the documented, intentional cost of fusing three
statistics passes into one over each column (see its docstring) -- left
alone, consistent with CLAUDE.md's guidance not to force-fix pre-existing
complexity that reflects a deliberate performance tradeoff.

uv run pytest: 1352 passed, 61 skipped (unchanged). ruff/vulture/jscpd
results unchanged from the previous commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wKWZn2btQ3Uo4grjuxxd2

* Fix CI: benchmark gate, ty check, and formatting

- benchmarks/cases.py: misaligned_matmat_vs_scipy/misaligned_rmatmat_vs_scipy
  still called peak_alloc_mb() after #56 dropped that helper entirely from
  benchmarks/harness.py, so every benchmark run failed with NameError. This
  was already broken on main (confirmed: main's own CI is red for the same
  reason), not something introduced by this branch, but it blocks this PR's
  gate too. Drops the two dead peak_alloc_mb entries (case output and
  baselines.json), matching how #56 already removed the other five
  memory-metric cases.
- _ivcsc.py/_rapid_load.py: the shared _decode_varint helper from an earlier
  commit here expects a np.int64 position, but both callers seeded `pos` as
  a plain `0`, which `ty check` flagged as a real (if numba-tolerated) type
  mismatch. Seed `pos = np.int64(0)` instead.
- _rapid_load.py: the _read_ivcsr_group extraction from the same earlier
  commit gives `kwargs` an explicit `dict[str, Any]` return type, which
  resolves the invalid-argument-type ty previously needed ignore comments
  for on `ad.AnnData(**kwargs)` -- removed the two now-unused ignores.
- ruff format on the three files touched by the complexity refactor.

uv run pytest: 1352 passed, 61 skipped. uv run python -m benchmarks.run
--set fast: no regressions. ruff check/format, ty check, codespell all pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wKWZn2btQ3Uo4grjuxxd2

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants