Skip to content

Let a container hold a normalized view, and stream it to a device in row blocks - #58

Draft
fishidaho wants to merge 5 commits into
mainfrom
feat/lazy-view-container
Draft

fishidaho wants to merge 5 commits into
mainfrom
feat/lazy-view-container

Conversation

@fishidaho

Copy link
Copy Markdown
Contributor

Draft. The GPU half needs a decision before review — see What is GPU and what is not below. #42 and #52 were both closed, so the device path may not be wanted at all; the container half stands on its own either way.

Problem

VCSCAnnData.X could hold only VCSCArray/VCSRArray, not a normalized view. Every caller wanting to put one in a container had to go through to_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**31 nonzeros: CuPy's csr_matrix derives 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

X accepts a NormalizedViewBase, and subsetting an AnnData routes through select(..., 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 roughly chunk_nnz nonzeros instead of uploading the matrix. 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. Decoded blocks are cached, so repeated passes do not re-decode off the packed view.

Measurements

Cohort BiCV, 2 ranks x 1 repeat:

wall host
lazy + CPU 488 s 45.3 GB
chunked GPU, no cache 755 s 45.0 GB
chunked GPU, cached blocks 468 s 47.1 GB

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**31 nonzeros 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_TYPES admits NormalizedViewBase, _subset_2d handles it, _check_vcs_type widened. Reviewable and testable on any machine.

Device path, GPU only_norm_common.py, 208 lines, almost all of it _DeviceNormalizedView plus to_device(). Every CuPy import is inside a method, so there is no new dependency and nothing imports CuPy unless to_device is called.

If the device path is not wanted, dropping it leaves the container change intact.

Known gaps

  • No tests. The container half is testable on CI and should get them before this is un-drafted. The device half cannot be tested without a GPU runner.
  • 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 — so this cannot currently share a card. Reusing one device buffer across blocks is the next step.

Merge order

Last, and only after the GPU question is settled.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Rvu3cf8ZL7F5EPX22eo6Je

fishidaho and others added 4 commits September 16, 2026 17:22
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
@fishidaho

Copy link
Copy Markdown
Contributor Author

Pushed two fixes found while verifying the branch:

  • raw_X no longer accepts a normalized view. Widening X had widened raw_X through the shared type check, which broke six test_raw_x_setter_validation cases. raw_X holds raw counts by definition, so only X takes the wider set now.
  • ruff. __slots__ unsorted and two calls wrapped narrower than the line length; the branch had never been linted.

Suite is green on the branch: 1261 passed, ty / ruff / codespell clean. The "no tests" gap in the description still stands — nothing here adds coverage for the new container or device paths.

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
fishidaho force-pushed the feat/lazy-view-container branch from 3f71e3f to a1891b0 Compare September 18, 2026 05:06
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.

1 participant