Conversation
Prototype for two gaps found while trying to run a cohort-scale BiCV sweep
(1,185,861 x 23,543, nnz 3.53e9) without materializing 42 GB of scipy CSR.
vsparse already produces a lazy, .select-able normalized view, and
scrise.rank_selection.bicv is already duck-typed to stream from exactly such
an object. Nothing could carry one between them: AnnData rejects it, and so
did VCSCAnnData. The only bridge was to_scipy_sparse(), which materializes.
Three pieces, not one:
- X/raw_X accept a NormalizedViewBase.
- _subset_value routes a view through select(..., recalculate=False), so
subsetting keeps the parent's statistics rather than renormalizing the
subset -- the semantics of slicing an already-normalized matrix.
- NormalizedViewBase.copy() shares the base array and copies only the
statistics. AnnData calls to_memory() defensively, which deep-copies X;
duplicating the base would defeat the point of being lazy.
to_device(backend) gives the view a GPU path, since parafac2's GPUMatrix
looks for exactly that hook and otherwise raises. It moves the sparse Delta
term and keeps the centering as a rank-1 correction applied on device, per
the identity `NormalizedViewBase.means` already documents. Two details the obvious
implementation gets wrong: nvmath's SpMM refuses mixed precision, so the
dense operand is cast to the sparse term's dtype (parafac2 does the same);
and __array_priority__ is needed or CuPy broadcasts instead of deferring to
__rmatmul__.
Measured, same probe (2 ranks x 1 repeat):
materialized + GPU 110.3 GB host, killed
lazy + CPU 48.5 GB host, completed
Known limit: to_device still builds the host scipy term before uploading,
and a full-cohort Delta is 42.3 GB (14.1 GB float32 values + 28.2 GB int64
indices, forced above 2**31 nnz) against a 24.5 GB card. Chunked transfer is
required there, not optional.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2NhBW7S6DcsF6bdwDGuaD
A bulk device copy is not an option above 2**31 nonzeros: CuPy's csr_matrix
derives one shared index dtype from the contents, so the indices widen to
int64 and the IBDverse cohort needs 42.3 GB (14.1 GB of float32 values plus
28.2 GB of indices) before any operand is allocated. On a 24.5 GB card that
fails at exactly the second allocation.
Nothing is uploaded up front now. Each product walks the view in row blocks of
roughly chunk_nnz nonzeros. Every block is individually under 2**31 nonzeros,
so its indices stay int32, and device residency is bounded by the block rather
than the dataset. Measured on the cohort: 42.3 GB demanded (OOM) -> 3.9 GB.
Because the blocks come off the lazy view, the host never builds the full
sparse term either. That is the one thing the equivalent chunking downstream
in cccRISE (f057209, removed by c3d53e1) could not avoid, since it sliced an
already-materialized CSR.
Three details carried over from that implementation, each of which silently
costs a lot if missed:
- Slice the CSR arrays directly. SciPy's row slicing copies data and
indices; taking views and rebuilding only the row pointer does not.
- dense @ sparse in CuPy routes through sum_duplicates, round-tripping the
block through COO. Use cupyx.cusparse.spmm(block, left, transa=True) with
an F-contiguous operand.
- cuSPARSE rejects a non-canonical CSR rather than canonicalizing it, and a
matrix rebuilt from raw index arrays carries no canonical flag.
Both operators return NumPy arrays: parafac2's GPUMatrix.matmul documents a
host array and its callers np.asarray() the result, which raises on a CuPy
array. Verified chunk-count independent against the CPU path -- 1 block and 9
blocks both agree to ~1e-7, float32 device precision.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2NhBW7S6DcsF6bdwDGuaD
Chunked to_device removed the OOM but was 1.5x slower than CPU, because a compression makes several raw-data passes and every one re-decoded the whole matrix off the packed view. Slicing an already-materialized CSR -- what the equivalent downstream implementation did -- pays the decode once, but needs one matrix, and above 2**31 nonzeros scipy forces its indices to int64: 42.3 GB for the IBDverse cohort. Caching the decoded blocks instead gets both. Each block is under 2**31 nonzeros by construction, so its indices stay int32, and the cache costs about a third less than the single int64 matrix slicing would have required (28.2 GB vs 42.3 GB). The decode is paid once, as it is when slicing. Measured on the cohort BiCV, 2 ranks x 1 repeat: lazy + CPU 488 s 45.3 GB host chunked GPU, no cache 755 s 45.0 GB host chunked GPU, cached blocks 468 s 47.1 GB host So the cache costs ~2 GB of host and buys back the whole regression, leaving GPU marginally ahead of CPU rather than well behind it. Results are unchanged throughout. Still open: device peak is 23.8 GB of a 24.5 GB card and sits above 20 GB for 37% of the run. That is dense intermediates and CuPy's non-returning pool, not the blocks -- reusing one device buffer across blocks is the next step, and until then this cannot share a card. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R2NhBW7S6DcsF6bdwDGuaD
Written before it had ever been linted: `__slots__` unsorted (RUF023) and two calls wrapped narrower than the configured line length. No behaviour change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rvu3cf8ZL7F5EPX22eo6Je
Contributor
Author
|
Pushed two fixes found while verifying the branch:
Suite is green on the branch: 1261 passed, |
Widening `X` to hold a normalized view widened `raw_X` with it, through the shared type check. `raw_X` holds the raw counts by definition, so a normalized view is not something it can meaningfully be, and six `test_raw_x_setter_validation` cases were failing on the changed message. Only `X` takes the wider set now; `raw_X`'s contract and its error are back to what they were. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rvu3cf8ZL7F5EPX22eo6Je
fishidaho
force-pushed
the
feat/lazy-view-container
branch
from
September 18, 2026 05:06
3f71e3f to
a1891b0
Compare
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
VCSCAnnData.Xcould hold onlyVCSCArray/VCSRArray, not a normalized view. Every caller wanting to put one in a container had to go throughto_scipy_sparse()first, materializing the whole matrix purely to have somewhere to put it — 42.3 GB on the IBDverse cohort — which defeats the point of a lazy,.select-able view.Separately, a bulk device copy is not possible above
2**31nonzeros: CuPy'scsr_matrixderives one shared index dtype from the contents, so indices widen to int64 and the cohort needs 42.3 GB (14.1 GB float32 values plus 28.2 GB indices) before any operand is allocated. On a 24.5 GB card that fails at the second allocation.Fix
Xaccepts aNormalizedViewBase, and subsetting an AnnData routes throughselect(..., recalculate=False)— keeping the view's statistics rather than renormalizing the subset, which is what slicing an already-normalized matrix means.to_device()walks the view in row blocks of roughlychunk_nnznonzeros instead of uploading the matrix. Every block is individually under2**31nonzeros, so its indices stay int32 and device residency is bounded by the block rather than the dataset. Decoded blocks are cached, so repeated passes do not re-decode off the packed view.Measurements
Cohort BiCV, 2 ranks x 1 repeat:
Device demand: 42.3 GB (OOM) → 3.9 GB. The block cache costs ~2 GB of host and buys back the whole regression; it is under
2**31nonzeros by construction, so its indices stay int32 and it costs about a third less than slicing one int64 matrix would (28.2 GB vs 42.3 GB). Results unchanged throughout.What is GPU and what is not
The two halves are separable and only one needs a GPU to review.
Container, no GPU —
_anndata_class.py, 31 lines._X_TYPESadmitsNormalizedViewBase,_subset_2dhandles it,_check_vcs_typewidened. Reviewable and testable on any machine.Device path, GPU only —
_norm_common.py, 208 lines, almost all of it_DeviceNormalizedViewplusto_device(). Every CuPy import is inside a method, so there is no new dependency and nothing imports CuPy unlessto_deviceis called.If the device path is not wanted, dropping it leaves the container change intact.
Known gaps
Merge order
Last, and only after the GPU question is settled.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Rvu3cf8ZL7F5EPX22eo6Je