Skip to content

Commit ddf8099

Browse files
authored
fix(resolvi): reduce peak memory during RESOLVI.load() (#3887)
1 parent 451d963 commit ddf8099

9 files changed

Lines changed: 71 additions & 21 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,20 @@ to [Semantic Versioning]. The full commit history is available in the [commit lo
1414
enabling memory-efficient training on large-scale datasets stored as sharded Zarr collections,
1515
with support for batch covariates, {pr}`3620`.
1616
- Add support for rapids-singlecell, {pr}`3811`.
17-
- Add {class}`scvi.external.CytoVI` KNN imputation backend option to be cuML, {pr}`3821`.
17+
- Add {class}`scvi.external.CYTOVI` KNN imputation backend option to be cuML, {pr}`3821`.
1818

1919
#### Fixed
2020

2121
- Fix list of metrics to be recorded in {class}`scvi.autotune.AutotuneExperiment`, {pr}`3816`.
22+
- Fix {class}`scvi.external.RESOLVI` preparing data for every load, {pr}`3887`.
2223

2324
#### Changed
2425

2526
- Changed {class}`scvi.external.Tangram` backend to be in Pytorch, {pr}`3786`.
2627
- Consolidate parts of the training and data loading between {class}`~scvi.external.GIMVI`
2728
and {class}`scvi.external.DIAGVI`, {pr}`3830`.
28-
- support validation set in DestVI training and raise clear errors for unsupported validation
29-
in RESOLVI, {pr}`3881`.
29+
- support validation set in {class}`scvi.model.DestVI` training and raise clear errors for
30+
unsupported validation in {class}`scvi.external.RESOLVI`, {pr}`3881`.
3031

3132
#### Removed
3233

docs/api/developer.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ DataLoaders for loading tensors from AnnData objects. DataSplitters for splittin
7272
7373
dataloaders.AnnDataLoader
7474
dataloaders.AnnTorchDataset
75+
dataloaders.AnnbatchDataModule
7576
dataloaders.CollectionAdapter
7677
dataloaders.ConcatDataLoader
7778
dataloaders.DataSplitter

src/scvi/external/decipher/_model.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
from scvi.data import AnnDataManager
1111
from scvi.data.fields import LayerField
1212
from scvi.model.base import BaseModelClass, PyroSviTrainMixin
13-
from scvi.train import PyroTrainingPlan
1413
from scvi.utils import setup_anndata_dsp
1514

1615
from ._module import DecipherPyroModule
@@ -88,7 +87,7 @@ def train(
8887
shuffle_set_split: bool = True,
8988
batch_size: int = 128,
9089
early_stopping: bool = False,
91-
training_plan: PyroTrainingPlan | None = None,
90+
training_plan: DecipherTrainingPlan | None = None,
9291
datasplitter_kwargs: dict | None = None,
9392
plan_kwargs: dict | None = None,
9493
**trainer_kwargs,
@@ -119,12 +118,12 @@ def train(
119118
early_stopping
120119
Perform early stopping. Additional arguments can be passed in ``**trainer_kwargs``.
121120
training_plan
122-
Training plan instance. If ``None``, a default :class:`~scvi.train.PyroTrainingPlan`
123-
is used.
121+
Training plan instance. If ``None``,
122+
a default :class:`~scvi.train.DecipherTrainingPlan` is used.
124123
datasplitter_kwargs
125124
Additional keyword arguments passed into :class:`~scvi.dataloaders.DataSplitter`.
126125
plan_kwargs
127-
Keyword arguments for :class:`~scvi.train.PyroTrainingPlan`.
126+
Keyword arguments for :class:`~scvi.train.DecipherTrainingPlan`.
128127
**trainer_kwargs
129128
Additional keyword arguments passed to :class:`~scvi.train.Trainer`.
130129
"""

src/scvi/external/decipher/_trainingplan.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@
44
from scvi.module.base import (
55
PyroBaseModuleClass,
66
)
7-
from scvi.train import LowLevelPyroTrainingPlan
7+
from scvi.train import PyroTrainingPlan
88

99

10-
class DecipherTrainingPlan(LowLevelPyroTrainingPlan):
10+
class DecipherTrainingPlan(PyroTrainingPlan):
1111
"""Lightning module task to train the Decipher Pyro module.
1212
1313
Parameters

src/scvi/external/resolvi/_model.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,21 @@ def setup_anndata(
355355
if prepare_data:
356356
if prepare_data_kwargs is None:
357357
prepare_data_kwargs = {}
358-
RESOLVI._prepare_data(adata, batch_key=batch_key, **prepare_data_kwargs)
358+
effective_config = {"batch_key": batch_key, **prepare_data_kwargs}
359+
stored_config = adata.uns.get("_resolvi_prepare_data_config", None)
360+
neighbors_valid = (
361+
stored_config == effective_config
362+
and "index_neighbor" in adata.obsm
363+
and "distance_neighbor" in adata.obsm
364+
and int(adata.obsm["index_neighbor"].max()) < adata.n_obs
365+
)
366+
if not neighbors_valid:
367+
print(
368+
"Preparing data for training. This may take a while. "
369+
"RAPIDS SingleCell will be used if installed."
370+
)
371+
RESOLVI._prepare_data(adata, batch_key=batch_key, **prepare_data_kwargs)
372+
adata.uns["_resolvi_prepare_data_config"] = effective_config
359373

360374
anndata_fields = [
361375
LayerField(REGISTRY_KEYS.X_KEY, layer, is_count_data=True),

src/scvi/module/base/_base_module.py

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -375,17 +375,31 @@ def list_obs_plate_vars(self):
375375
return {"name": "", "in": [], "sites": {}}
376376

377377
def on_load(self, model, **kwargs):
378-
"""Callback function run in :meth:`~scvi.model.base.BaseModelClass.load`.
379-
380-
For some Pyro modules with AutoGuides, run one training step prior to loading state dict.
381-
"""
378+
"""Callback function run in :meth:`~scvi.model.base.BaseModelClass.load`."""
382379
pyro.clear_param_store()
383380
old_history = model.history_.copy() if model.history_ is not None else None
384-
model.train(max_steps=1, **self.on_load_kwargs)
385381
model.history_ = old_history
386-
if "pyro_param_store" in kwargs:
387-
# For scArches shapes are changed, and we don't want to overwrite these changed shapes.
388-
pyro.get_param_store().set_state(kwargs["pyro_param_store"])
382+
if "pyro_param_store" in kwargs and kwargs["pyro_param_store"] is not None:
383+
store_state = kwargs["pyro_param_store"]
384+
# AutoGuide parameters are lazy — they only exist as nn.Module attributes
385+
# after the first forward call. Run one no-grad guide pass BEFORE restoring
386+
# the saved store so the CPU module always sees a device-neutral empty store.
387+
# Restoring first would expose CUDA constraint tensors (e.g. AffineTransform
388+
# bounds derived from CUDA buffers) that clash with the CPU batch tensors.
389+
if model.adata is not None:
390+
if hasattr(self.model, "n_obs"):
391+
self.model.n_obs = model.adata.n_obs
392+
if hasattr(self.guide, "n_obs"):
393+
self.guide.n_obs = model.adata.n_obs
394+
dl = model._make_data_loader(model.adata, batch_size=2)
395+
batch = next(iter(dl))
396+
args, guide_kwargs = self._get_fn_args_from_batch(batch)
397+
with torch.no_grad():
398+
self.guide(*args, **guide_kwargs)
399+
# Restore the saved store after warmup. For scArches the saved store
400+
# carries old reference shapes — those are intentionally kept here.
401+
# load_state_dict + model.to_device() handle the final device placement.
402+
pyro.get_param_store().set_state(store_state)
389403

390404
def create_predictive(
391405
self,

tests/external/decipher/test_decipher.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,27 @@ def test_decipher_model_methods(adata):
6868
assert z_cov.shape == (adata.n_vars, model.module.dim_z)
6969

7070

71+
def test_decipher_save_load(adata, save_path):
72+
import os
73+
74+
Decipher.setup_anndata(adata)
75+
model = Decipher(adata)
76+
model.train(max_epochs=2, train_size=0.5)
77+
78+
v_before = model.get_latent_representation(give_z=False)
79+
z_before = model.get_latent_representation(give_z=True)
80+
81+
path = os.path.join(save_path, "test_decipher")
82+
model.save(path, overwrite=True, save_anndata=True)
83+
model2 = Decipher.load(path)
84+
85+
v_after = model2.get_latent_representation(give_z=False)
86+
z_after = model2.get_latent_representation(give_z=True)
87+
88+
np.testing.assert_array_almost_equal(v_before, v_after, decimal=4)
89+
np.testing.assert_array_almost_equal(z_before, z_after, decimal=4)
90+
91+
7192
def test_decipher_trajectory(adata):
7293
Decipher.setup_anndata(adata)
7394
model = Decipher(adata)

tests/model/test_amortizedlda.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ def test_lda_model(n_topics: int):
9696

9797

9898
@pytest.mark.parametrize("n_topics", [5])
99-
def test_lda_model_save_load(save_path: str, n_topics: int):
99+
def test_lda_model_save_load(n_topics: int, save_path: str):
100100
adata = synthetic_iid()
101101
AmortizedLDA.setup_anndata(adata)
102102
mod = AmortizedLDA(adata, n_topics=n_topics)

tests/model/test_pyro.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,7 @@ def test_pyro_bayesian_save_load(save_path):
339339
linear_median = quants["linear.weight"][0].detach().cpu().numpy()
340340

341341
model_save_path = os.path.join(save_path, "test_pyro_bayesian/")
342-
mod.save(model_save_path)
342+
mod.save(model_save_path, overwrite=True, save_anndata=True)
343343

344344
# Test setting `on_load_kwargs`
345345
mod.module.on_load_kwargs = {"batch_size": 8}

0 commit comments

Comments
 (0)