Out-of-core processing (qc/normalize/log1p/hvg/pca/pseudobulk) with deterministic parallelism - #2
Open
iandriver wants to merge 11 commits into
Open
Out-of-core processing (qc/normalize/log1p/hvg/pca/pseudobulk) with deterministic parallelism#2iandriver wants to merge 11 commits into
iandriver wants to merge 11 commits into
Conversation
…e shim
Implements native streaming (out-of-core) preprocessing on disk-backed
.h5ad — the highest-leverage item from the performance roadmap, targeting
the larger-than-RAM niche scanpy fills with Dask (where Dask+sparse is weak).
- backed::processing::transformation: log1p_backed + normalize_total_backed.
Read X in row-chunks (x().iter), transform, stream back out via
set_x_from_iter (extendable HDF5 dataset). Peak memory ~ one chunk, not
the dataset. normalize is two passes (cheap per-cell sums, then scale).
- backed::processing::qc: qc_metrics_backed — one streaming pass computing
the same cell/gene metrics as the in-memory version (totals, nnz, top-N
segments, mito), written back into obs/var in place (X untouched).
- examples/sr_ooc.rs: CLI (qc | normalize_total | log1p) with in-place
rewrite (temp + atomic rename) so a file can be processed without loading.
- demo/singlerust.py: near-drop-in `sr.pp.*` mirroring `sc.pp.*`
(calculate_qc_metrics / normalize_total / log1p), operating on a backed
AnnData's file or a path; releases the HDF5 handle before invoking Rust.
Validated against scanpy on a 200×600 dataset: X exact match after
normalize_total+log1p, and total_counts / n_genes / pct_counts_mito /
pct_top_{50,500} / var totals / n_cells / mean all match.
Also surfaced a pre-existing bug (flagged separately): convert_to_array_f64
corrupts CSR values when densifying — the OOC tests densify manually.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
bench_ooc.py runs the same work (QC + normalize_total + log1p) in both lanes as subprocesses under /usr/bin/time -l, capturing peak RSS + time. Result at 500k cells × 48,788 genes (48 GB / 18-core): scanpy in-memory : 12.3s compute | 12.4 GB peak RSS SingleRust OOC : 44.9s wall | 2.3 GB peak RSS (~5.3× less memory) SingleRust OOC's RSS is chunk-bounded (≈flat as cells grow); scanpy in-memory scales linearly and OOMs (~50 GB at 2M > 48 GB RAM). Time is not apples-to-apples (scanpy = compute only; SingleRust includes a 6 GB working copy + two full disk passes as separate processes) — documented in OOC_BENCHMARK.md; the memory result is the durable signal. The scanpy+Dask out-of-core lane is left as a hook: it needs anndata>=0.11 (read_elem_as_dask; this env has 0.10.9) and the Dask sparse path is immature per scanpy's own issues. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a third lane (_scanpy_dask_pp.py) using anndata's experimental read_elem_lazy to read X as a dask array, run dask-enabled normalize_total/log1p (+qc), and stream the result out. bench_ooc.py now compares all three lanes (peak RSS + time), driving the scanpy lanes with an isolated .venv-dask (anndata 0.12 / scanpy 1.12 / dask), since the lazy dask read needs anndata>=0.11 (Python>=3.10). Result at 500k × 48,788 genes (48 GB / 18-core): scanpy in-memory : 13.5s | 11.7 GB scanpy + Dask OOC : 92.2s | 7.5 GB SingleRust OOC : 128s | 2.1 GB Key finding: Dask's sparse OOC path only modestly cuts memory (7.5 vs 11.7 GB) while costing ~7× the runtime — the dask-sparse immaturity scanpy's own issues describe. SingleRust native streaming is the only lane with truly bounded memory (5.6× less than in-memory, 3.6× less than Dask). 2M projection documented but not run (would OOM the box); peak RSS is the fair metric (time isn't apples-to-apples — see OOC_BENCHMARK.md). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e too preprocess_backed runs QC + normalize_total + log1p as one streamed job: pass 1 accumulates QC metrics (the per-cell total IS the normalization row-sum, so it's computed once); pass 2 streams normalize+log1p into X. Two passes total instead of the ~4 read/write passes the separate ops incurred, and no working copy. - src/backed/processing/pipeline.rs: preprocess_backed (+ test). - qc.rs: extracted stream_qc / write_metrics (pub(crate)) so the pipeline reuses the QC accumulation and its totals. - transformation.rs: normalize_chunk made pub(crate) for reuse. - examples/sr_ooc.rs: `preprocess` subcommand. - demo/singlerust.py: sr.pp.preprocess. - demo/bench_ooc.py: SingleRust lane now a single fused, copy-free call. Benchmark (500k × 48,788 genes, 48 GB / 18-core): scanpy in-memory : 13.5s | 13.6 GB scanpy + Dask OOC : 35.4s | 14.5 GB SingleRust OOC : 45.0s | 2.5 GB Fusion cut SingleRust wall time 128s -> 45s (~2.8×): now in the same ballpark as Dask on time, at ~1/6th the memory. Dask's sparse OOC again showed no memory benefit (RSS >= in-memory). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
One streaming pass accumulates per-gene sum + sum-of-squares; mean and Bessel-corrected sample variance are formed identically to the in-memory var_col, then handed to the shared seurat_select (refactored out of the in-memory compute_seurat_hvg) for dispersion binning/normalization and top-N selection. Results written into var in place; memory bounded by one chunk + two n_vars accumulators. - memory/processing/hvg: extract pub(crate) seurat_select; module made pub(crate) so the backed path can reuse it. - backed/processing/hvg.rs: highly_variable_genes_backed (+ test asserting the OOC mask equals the in-memory HVG mask on the same data). - sr_ooc CLI `hvg` + sr.pp.highly_variable_genes shim. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Exact PCA over the HVG-selected genes without holding the matrix in
memory:
pass 1: stream X, accumulate the gene×gene Gram matrix + per-gene sums
over selected genes (memory O(n_hvg²), e.g. 32 MB for 2000 HVGs)
-> centered covariance C = (G - n·μμᵀ)/(n-1), symmetric eigendecomp
(n_hvg × n_hvg) for loadings + variances
pass 2: stream X again, project each centered cell onto the top axes
-> obsm["X_pca"], uns["pca_variance_ratio"]; written in place.
Centering is folded into the projection (per-component offset), so pass 2
only touches each cell's selected nonzeros. Exact (not randomized) PCA;
matches a dense covariance-PCA reference (variance ratios exact, embedding
column norms match) up to per-component sign — see test.
- backed/processing/pca.rs: pca_backed (+ dense-reference test).
- sr_ooc CLI `pca` + sr.pp.pca shim.
- hvg: add SeuratResult type alias (clippy type-complexity).
Out-of-core Tier-1 is now complete: qc, normalize/log1p, fused preprocess,
HVG, and PCA all run disk-backed in bounded memory.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
OOC_BENCHMARK.md: summarize that QC, normalize_total, log1p, fused preprocess, HVG (Seurat) and PCA all run disk-backed via sr_ooc / sr.pp.*, with each step validated against its in-memory/dense counterpart. Note the end-to-end PCA-vs-scanpy axis difference traces to SingleRust's HVG gene selection differing from scanpy's (pre-existing), not the OOC path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Parallelize the compute-bound OOC reductions with rayon, but guarantee bit-identical results regardless of thread count via a fixed-block, ordered-merge reduction (backed::processing::det). Floating-point sums are non-associative and rayon work-stealing varies the order, so partials are computed over fixed-index row blocks and merged in block order -> the summation order depends only on data size + a constant block size, never on threads or scheduling. Parallelized: - PCA pass-1 Gram matrix + per-gene sums (the compute hotspot) via det_block_reduce; pass-2 projection is per-cell independent (parallel, order-free). - HVG per-gene sum/sum-of-squares via det_block_reduce. - QC per-cell metrics + per-gene reduction via det_block_reduce (per-cell records kept in row order, per-gene summed in block order). normalize/log1p stay sequential (trivial elementwise, no reduction) and are deterministic by construction. Determinism verified: - unit tests: qc/hvg/preprocess/pca under 1 vs 8 rayon threads are bit-identical (metrics, masks, embeddings, variance ratios), plus a repeat-run check. - at scale: full pipeline on 50k real cells with RAYON_NUM_THREADS=1 vs 18 yields bit-identical X, obs/var, obsm["X_pca"], and uns variance ratios. Adds rayon as a direct dependency. 21 lib tests pass; clippy clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…decoupler decoupler.pp.pseudobulk loops the full sample×group cartesian product and, per combination, boolean-masks all cells and densifies the submatrix (X[mask].toarray()) with the whole input resident — O(n_obs·n_groups) masking + per-group densification. pseudobulk_backed does it in ONE streaming sparse scatter-add (each cell -> its group accumulator): O(nnz), one pass, only the small groups×genes output resident. - backed/processing/pseudobulk.rs: pseudobulk_backed (sum/mean), sample×group cartesian rows in decoupler order, X aggregate + obs psbulk_cells/ psbulk_counts + layers["psbulk_props"]. Sequential scatter-add -> deterministic by construction. Unit test vs manual sums. - sr_ooc CLI `pseudobulk` + sr.pp.pseudobulk shim (mirrors dc.pp.pseudobulk). - demo/bench_pseudobulk.py + _decoupler_psb.py: head-to-head harness. Benchmark (500k cells × 48,788 genes, 12 donors × 143 cell types = 1,716 groups, 48 GB / 18-core): decoupler (in-mem) : 33.0s | 18.0 GB SingleRust OOC : 8.1s | 3.2 GB ~4.1x faster, ~5.6x less memory, aggregate sums BIT-IDENTICAL (max abs diff 0 over all 1,716 groups). Time gap is conservative — decoupler's number excludes its data load, SingleRust's includes the file read. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Parallelize the pseudobulk scatter-add while keeping it bit-deterministic. Group accumulators are partitioned across a fixed 16 buckets (group % 16); each bucket is owned by one thread (disjoint &mut Partition via par_iter_mut), so cells of a given group are always summed by the same thread in cell order — no cross-thread merge of any group, no duplicated full accumulator. Bit-identical regardless of thread count; the constant partition count keeps it reproducible across machines too. Benchmark (500k × 48,788 genes, 1,716 groups): SingleRust 8.1s -> 5.1s (now ~5.9x faster than decoupler, ~4.7x less memory), aggregate sums still bit-identical. Partition buffers add ~1.4 GB vs the sequential version. Verified: unit test (1 vs 8 threads bit-identical) + at scale (RAYON_NUM_THREADS=1 vs 18 on 500k -> identical X/counts/props). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds demo/_replicate_cells.py, which tiles an existing CSR .h5ad along obs out-of-core to build larger inputs while holding the sample x group cardinality constant, so the comparison isolates cell-count scaling. Tiling doubles as an exactness check: the R-tiled sums must equal R x the source's. SingleRust pseudobulk holds ~2.4-3.6 GB peak RSS from 500k to 2M cells and scales sub-linearly in time (4x cells -> 2.1x time), since both peak memory and the fixed output-write cost depend on group count, not cell count. Exact at every size: 0 diff vs decoupler at 1M, and 0 diff vs the 2x/4x-scaled 500k reference. The 2M input needs an int64 CSR indptr (nnz = 3.01e9 overflows int32); this reads fine. Caveats recorded in the doc rather than smoothed over: the 1M decoupler run was contaminated by an unrelated 8-16 GB process and was paging (185 s wall vs 122 s CPU), so its time is inflated and its peak RSS understates true demand; 2M decoupler was skipped as unsafe on a machine with 16 GB spoken for and swap 98% used. Also corrects the 500k SingleRust figure from 5.1 s to 12.2 s -- the old number ran off a page cache warmed by decoupler reading the same file first, making the reported gap ~2.4x, not ~5.9x. Co-Authored-By: Claude Opus 4.8 <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.
Summary
Implements native out-of-core (disk-backed) processing for SingleRust — the flagship item from
the performance roadmap. The whole Tier-1 pipeline (QC → normalize_total → log1p → HVG → PCA) plus
decoupler-style pseudobulk aggregation now run streamed against an
.h5adin boundedmemory, the larger-than-RAM niche scanpy fills with Dask (where, per scanpy's own issues, the
sparse path is weak). All compute-bound passes are parallelized with a determinism guarantee:
results are bit-identical regardless of thread count.
Stacked on
demo/scverse-integration, so this PR's diff is just the OOC engine + benchmarks.What's new
OOC engine (
src/backed/processing/) — streamsXin row-chunks (x().iter) and writes backvia
set_x_from_iter(extendable HDF5 dataset); peak memory ≈ one chunk + small accumulators:transformation:log1p_backed,normalize_total_backedqc:qc_metrics_backed(one pass; obs/var written in place)pipeline:preprocess_backed— fused QC+normalize+log1p (the per-cell total is reused as thenormalization row-sum, so it's computed once)
hvg:highly_variable_genes_backed(Seurat) — sharesseurat_selectwith the in-memory pathpca:pca_backed— exact covariance/eigendecomposition PCA over the HVGs (pass-1 Gram matrix,symmetric eigendecomp, pass-2 projection →
obsm["X_pca"])pseudobulk:pseudobulk_backed—sample × groupaggregation (sum/mean) in a singlestreaming sparse scatter-add, matching
dc.pp.pseudobulk's outputs (obs["psbulk_cells"],obs["psbulk_counts"],layers["psbulk_props"], group-major row order)Deterministic parallelism — naive parallel FP reduction is not reproducible (non-associative
addition + work-stealing), so two schemes are used depending on the access pattern:
backed::processing::det): fixed-block, ordered-merge — rows are cut intofixed-size blocks at fixed indices, each folded sequentially, partials merged in block order. The
summation order depends only on data size + a constant block size, never on threads/scheduling.
Applied to PCA Gram, HVG sum/sumsq, and QC accumulation.
group % 16),each owned by one thread. Partitions are disjoint slices of the output (no data race, no
unsafe,no per-thread copy of the full accumulator), and every group is summed by a single thread in cell
order — so no cross-thread merge ever happens. The partition count is a constant, so results
reproduce across machines, not just across thread counts.
Drop-in scanpy interface —
examples/sr_ooc.rsCLI(
qc|normalize_total|log1p|preprocess|hvg|pca|pseudobulk, in-place via temp+rename) anddemo/singlerust.pymirroringsc.pp.*:sc.pp.calculate_qc_metrics(adata)sr.pp.calculate_qc_metrics(adata)sc.pp.normalize_total(adata, target_sum=1e4)sr.pp.normalize_total(adata, target_sum=1e4)sc.pp.log1p(adata)sr.pp.log1p(adata)sc.pp.highly_variable_genes(adata, …)sr.pp.highly_variable_genes(adata, …)sc.pp.pca(adata, …)sr.pp.pca(adata, …)dc.pp.pseudobulk(adata, sample_col, groups_col)sr.pp.pseudobulk(adata, sample_col, groups_col)adatais a path or a backedAnnData; the shim releases the HDF5 handle before invoking Rust.Benchmarks —
demo/bench_ooc.py(3 lanes: scanpy in-memory vs scanpy+Dask vs SingleRust OOC)and
demo/bench_pseudobulk.py(decoupler vs SingleRust), each lane run under/usr/bin/time -lfor peak RSS.
Results
All on 500k cells × 48,788 genes (48 GB / 18-core).
Preprocessing (QC + normalize_total + log1p):
SingleRust OOC uses ~5.4× less memory than scanpy in-memory and ~5.8× less than scanpy+Dask,
with a chunk-bounded footprint that stays flat as cells grow (scanpy OOMs ~46 GB at 2M). Dask's
sparse OOC path gave no memory benefit (RSS ≥ in-memory) at 2.6× the time — the immaturity
scanpy's issues describe.
Pseudobulk, scaled 500k → 2M cells (12 donors × 143 cell types = 1,716 groups held constant, so
this isolates cell-count scaling; 1M/2M inputs are the 500k dataset tiled along obs by
demo/_replicate_cells.py):SingleRust's memory stays flat (~2.4–3.6 GB) from 500k to 2M and its time is sub-linear — 4× the
cells costs 2.1× the time. Both follow from the design: peak RSS is the fixed
groups × genesaccumulator plus one chunk (neither depends on cell count), and the fixed output allocate/write cost
amortizes as cells grow. decoupler loops the full sample×group cartesian product and, for each,
boolean-masks all cells and densifies the submatrix —
O(n_obs · n_groups)masking plusper-group densification with the whole input resident — so its cost grows with cells even at
constant group count.
Results are exact at every size: aggregate-sum max abs diff = 0 vs decoupler at 1M over all
1,716 groups. Tiling also gives a free self-check — the 1M/2M sums must equal exactly 2×/4× the 500k
sums, and they do (diff 0,
psbulk_cellslikewise).⚠ Please read the caveats below before quoting the decoupler comparison.
Correctness & determinism
Each step is unit-tested against its same-algorithm counterpart: log1p/normalize match scanpy's X
exactly; QC matches; HVG selects the same genes as in-memory HVG; PCA matches a dense
covariance-PCA reference (variance ratios exact, embedding norms match up to sign); pseudobulk
matches decoupler's aggregate sums exactly.
Determinism verified two ways:
RAYON_NUM_THREADS=1vs18→ bit-identical X, obs/var,obsm["X_pca"], variance ratios,and pseudobulk aggregates/counts/props.
Benchmark caveats
Recording these rather than smoothing them over — the SingleRust columns are clean, but two of the
decoupler figures are not, and one previously-reported number was wrong.
process was on the machine for part of it and decoupler was paging: 185 s wall against only 122 s
CPU (71 user + 51 sys). Its true quiet-machine time is lower than 167.8 s. Read the 1M row as
"decoupler degrades sharply once it stops fitting," not as a precise 8.7×.
mid-run as the OS evicted pages, which is why 1M reads lower than 500k despite twice the data.
Once a lane swaps, RSS is a ceiling on residency, not on demand.
4.6 GB. That run followed decoupler over the same file and so read from a warm page cache; cold,
it is 12.2 s. The honest gap at 500k is ~2.4×, not ~5.9×.
time a neighbor process held 16.2 GB and swap was 98% used, so attempting it risked destabilizing
the machine rather than producing a usable number.
(excludes its data load), SingleRust's is wall time including read and write.
Notes for reviewers
rayonas a direct dependency. 23 lib tests pass; clippy clean (one pre-existing dead-codewarning unrelated to this PR).
int64 CSR
indptr; SingleRust reads it fine, but it is a real cliff for any 32-bit index path.speedup; this scales with group count, so it is negligible for the coarser groupings typical of
pseudobulk DE.
gene set than scanpy's — a pre-existing in-memory difference, not introduced here.
convert_to_array_f64CSR densify bug; the OOCtests densify manually to avoid it.
.venv-dask(anndata ≥ 0.11 / scanpy ≥ 1.11); the main3.9 demo env is untouched.
🤖 Generated with Claude Code