Skip to content

Scale batched GPU NMF factorize to millions of cells#7

Merged
OlivierBakker merged 3 commits into
TrynkaLab:mainfrom
JerryIshihara:fix/scalable-recon-error
Jul 7, 2026
Merged

Scale batched GPU NMF factorize to millions of cells#7
OlivierBakker merged 3 commits into
TrynkaLab:mainfrom
JerryIshihara:fix/scalable-recon-error

Conversation

@JerryIshihara

Copy link
Copy Markdown

Summary

Three scalability fixes to the batched GPU NMF factorize (--engine gpu --gpu-batch,
added in #6), all in src/cnmf/nmf_gpu.py. They were surfaced by running cNMF
factorize on a 2.4M-cell Perturb-seq dataset on an A100-80GB, where the batched GPU
path hit three separate scaling walls.

Motivation

At batch=4 on 2.4M cells × 2000 genes, factorize_gpu failed in three independent
ways: an OOM-scale convergence check, a torch.dot integer overflow, and repeated re-
densification of the sparse matrix. This PR makes the batched GPU factorize usable at
millions of cells.

Changes

  1. Identity reconstruction error (_recon_err). The convergence check
    torch.linalg.norm(X − W@H) materialized the full [R, n, g] residual (R replicates ×
    2.4M cells × 2000 genes ≈ tens of GB). Replaced with the algebraic identity ‖X−WH‖² =
    ‖X‖² − 2⟨X,WH⟩ + ‖WH‖², computed from small [R,k,g]/[R,k,k] matmuls (WᵀX, WᵀW, HHᵀ) —
    it never forms the large product.

  2. int32-safe ‖X‖² (_sq_norm). Computing ‖X‖² as a flat torch.dot(X.reshape(-1), X.reshape(-1)) overflows the BLAS int32 vector-length cap (2³¹ − 1): 2.4M × 2000 = 4.8B
    elements crashed with bound <= 2147483647. Now reduced in ~2²⁸-element row chunks,
    index-safe at any size.

  3. Densify once in factorize_gpu. The sparse norm_counts.X was passed straight to
    _nmf_gpu_mu, whose _gpu_setup calls .toarray() — so every batch re-densified the
    whole n×g matrix (O(#batches) redundant conversions + RAM churn). Densify once up front;
    downstream sees a dense ndarray.

Validation

  • Full factorize at 2.4M cells, A100-80GB, batch=4, tf32: completed 1,200 replicates
    (K = 2,4,6,12,16,18,20,22,24,36,48,60 × 100 iters) in ~9.8 h with no OOM and flat
    ~55 GB VRAM across all K
    (peak 55,945 MiB at k=2 through k=60), GPU at ~99% util.

_fit_mu / _fit_mu_fixed_h computed the reconstruction error as norm(X - W@H),
which materializes the full [R, n_cells, n_genes] product at every convergence
check — tens of GB, and the OOM ceiling for batched fits at 1e6+ cells (e.g.
batch=2 at 2.4M x 2000 needs ~2x38 GB just for that residual).

Replace it with _recon_err, the identity ||X-WH||^2 = ||X||^2 - 2<X,WH> + ||WH||^2
computed from the small WtX [R,k,g], WtW [R,k,k], HHt [R,k,k] matmuls (||X||^2
precomputed once) — no [R,n,g] tensor is ever formed. The value is identical to
the direct norm (verified 2D and batched), so convergence / early-stop behavior
is unchanged; only peak memory drops (batch>1 at 2.4M now fits on an 80 GB GPU).
The identity recon-error check added in a59acd7 precomputed ‖X‖² with
torch.dot(X.reshape(-1), X.reshape(-1)). torch.dot is BLAS-backed and caps
its vector length at 2³¹-1 elements, so it raises RuntimeError on X with
more than ~2.1B entries (2.4M cells × 2000 genes = 4.8B) before the first
MU step — defeating the scaling goal of a59acd7.

Replace with _sq_norm(): a chunked square-sum reduction that is index-safe
at any size and also bounds the squaring temporary. Verified on CUDA to
match a float64 reference to 6e-08 on a 2.2B-element tensor (where torch.dot
raises); CD4 2.4M×2000 batch=4 factorize now runs at ~55 GB on an 80 GB A100.
factorize_gpu read norm_counts once but passed the sparse X into every
_nmf_gpu_mu batch call; _gpu_setup then ran X.toarray() (+ isfinite/min/
ascontiguousarray) on each launch. At millions of cells with many k-values
that re-densifies the full n×g matrix O(#batches) times — dominating runtime
(~4 reps/min observed) and churning tens of GB of RAM per batch, which risks
OOM in the eager path. Matches the module's own "sparse X is still densified
by this prototype" TODO.

Densify X once before the batch loop and pass the dense ndarray downstream
(toarray then becomes a no-op). NOTE: root-caused from the CD4 2.4M run but
not yet runtime-validated — the pod died before I could re-run; needs a CUDA
smoke test before merge.
@OlivierBakker OlivierBakker merged commit 109a6ed into TrynkaLab:main Jul 7, 2026
1 check passed
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