Assert memory ceilings with pytest-memray, and drop the benchmark memory metrics - #56
Merged
Merged
Conversation
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>
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
force-pushed
the
infra/pytest-memray
branch
from
September 18, 2026 05:03
d8eef54 to
4d04032
Compare
Contributor
Author
|
Removed the The Docstrings and comments trimmed throughout to say what the code does rather than how it got here. |
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 isnthreads * n_minor * width * 8bytes, 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-memrayceilings intests/, 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--memrayflag, so CI needs no change.Three conventions keep the ceilings meaningful:
pinned_threads(new, inconftest.py) fixes numba's thread count, so the expected allocation is a property of the code and not the machine.pytest.param, neverrequest.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:
minor_sum_peak_mbtest_minor_axis_reductions_allocate_nothing_nnz_sized[sum]minor_extrema_peak_mb...[max]minor_getnnz_peak_mb...[getnnz]minor_selection_peak_mbtest_minor_axis_selection_allocates_no_nnz_sized_scratchmisaligned_matmul_peak_mbtest_misaligned_matmul_peak_is_bounded_by_the_accumulator_budgetpeak_alloc_mb_view(30 cases)Layout size and throughput stay:
bytes_per_nonzerois 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:
Every ceiling was checked by tightening it to 1 KB and confirming it fails. The
limit_leakstest 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.mdas diagnosable but not reliably gateable.Merge order
After #55, which it conflicts with in
benchmarks/: the cases #55 adds carrypeak_alloc_mbkeys this PR removes. Landing second means dropping those keys.🤖 Generated with Claude Code
https://claude.ai/code/session_01Rvu3cf8ZL7F5EPX22eo6Je