Skip to content

Commit 52f3140

Browse files
committed
Update composition and scaler to use mts.nn.Module
1 parent a8dccbc commit 52f3140

32 files changed

Lines changed: 142 additions & 514 deletions

src/metatrain/composition/__init__.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,6 @@ def train_or_load_composition_model(
8686
f"'{target_info.unit}'."
8787
)
8888
composition_model.load_state_dict(loaded.state_dict())
89-
composition_model.sync_tensor_maps()
9089
else:
9190
assert isinstance(atomic_baseline, dict)
9291
logging.info("Calculating composition weights")

src/metatrain/composition/_base_composition.py

Lines changed: 7 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,13 @@
1010
import metatensor.torch as mts
1111
import torch
1212
from metatensor.torch import Labels, LabelsEntry, TensorBlock, TensorMap
13+
from metatensor.torch.learn import nn
1314
from metatomic.torch import ModelOutput, System
1415

1516
from .documentation import FixedCompositionWeights # noqa: F401
1617

1718

18-
class BaseCompositionModel(torch.nn.Module):
19+
class BaseCompositionModel(nn.Module):
1920
"""
2021
Fits a composition model for a dict of targets.
2122
@@ -88,9 +89,11 @@ def __init__(
8889
self.atomic_types = torch.as_tensor(atomic_types, dtype=torch.int32)
8990
self.target_names = []
9091
self.sample_kinds = {}
91-
self.XTX = {}
92-
self.XTY = {}
93-
self.weights = {}
92+
# `XTX` and `XTY` are only used during fitting, not at inference, so they
93+
# are registered as non-persistent buffers to keep them out of the state_dict.
94+
self.register_buffer("XTX", {}, persistent=False)
95+
self.register_buffer("XTY", {}, persistent=False)
96+
self.register_buffer("weights", {})
9497

9598
# go from an atomic type to its position in `self.atomic_types`
9699
self.register_buffer(
@@ -242,7 +245,6 @@ def accumulate(
242245

243246
device = systems[0].positions.device
244247
dtype = systems[0].positions.dtype
245-
self._sync_device_dtype(device, dtype)
246248

247249
# check that the systems contain no unexpected atom types
248250
for system in systems:
@@ -486,10 +488,7 @@ def forward(
486488
:raises ValueError: If no weights have been computed or if `outputs` keys
487489
contain unsupported keys.
488490
"""
489-
490491
device = systems[0].positions.device
491-
dtype = systems[0].positions.dtype
492-
self._sync_device_dtype(device, dtype)
493492

494493
# Build the sample labels that are required
495494
_, sample_labels = _get_system_indices_and_labels(systems, device)
@@ -616,32 +615,6 @@ def _compute_X_per_atom(
616615
)
617616
return one_hot_encoding.to(dtype)
618617

619-
def _sync_device_dtype(self, device: torch.device, dtype: torch.dtype) -> None:
620-
"""
621-
Move the accumulated quantities and the fitted weights to the given
622-
device and dtype.
623-
624-
Needed because they are stored as ``TensorMap`` dicts, which
625-
``torch.nn.Module.to`` does not move.
626-
627-
:param device: Device to move the quantities to.
628-
:param dtype: Dtype to convert the quantities to.
629-
"""
630-
self.atomic_types = self.atomic_types.to(device=device)
631-
self.type_to_index = self.type_to_index.to(device=device)
632-
self.XTX = {
633-
target_name: tm.to(device=device, dtype=dtype)
634-
for target_name, tm in self.XTX.items()
635-
}
636-
self.XTY = {
637-
target_name: tm.to(device=device, dtype=dtype)
638-
for target_name, tm in self.XTY.items()
639-
}
640-
self.weights = {
641-
target_name: tm.to(device=device, dtype=dtype)
642-
for target_name, tm in self.weights.items()
643-
}
644-
645618

646619
def _include_key(key: LabelsEntry) -> bool:
647620
"""

src/metatrain/composition/checkpoints.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,48 @@
1+
import torch
2+
3+
4+
def model_update_v1_v2(checkpoint: dict) -> None:
5+
"""
6+
Update model checkpoint from version 1 to version 2.
7+
8+
The model now uses metatensor's ``nn.Module`` and ``register_buffer`` instead of
9+
manually tracking the data.
10+
11+
:param checkpoint: The checkpoint to update.
12+
"""
13+
scaler_key = "model"
14+
15+
for key in ["model_state_dict", "best_model_state_dict"]:
16+
if (state_dict := checkpoint.get(key)) is None:
17+
continue
18+
19+
# If both model_state_dict and best_model_state_dict point to the same
20+
# dict, the upgrade was already applied in the first iteration.
21+
if f"{scaler_key}._mts_helper" in state_dict:
22+
continue
23+
24+
dummy_buffer = state_dict["dummy_buffer"]
25+
empty_tensor = torch.zeros(
26+
0, dtype=dummy_buffer.dtype, device=dummy_buffer.device
27+
)
28+
29+
extra_state: dict[str, dict] = {"weights": {}}
30+
31+
for target_name in checkpoint["model_data"]["dataset_info"].targets:
32+
buffer_key = f"{target_name}_composition_buffer"
33+
if buffer_key not in state_dict:
34+
continue
35+
36+
extra_state["weights"][target_name] = (
37+
"metatensor.TensorMap",
38+
state_dict.pop(buffer_key),
39+
empty_tensor,
40+
)
41+
42+
state_dict[f"{scaler_key}._mts_helper"] = empty_tensor
43+
state_dict[f"{scaler_key}._extra_state"] = extra_state
44+
45+
146
def trainer_update_v1_v2(checkpoint: dict) -> None:
247
"""
348
Update a v1 Trainer checkpoint to v2.

src/metatrain/composition/model.py

Lines changed: 4 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,8 @@
22
import warnings
33
from typing import Dict, List, Literal, Optional, Union
44

5-
import metatensor.torch as mts
65
import torch
7-
from metatensor.torch import Labels, TensorBlock, TensorMap
6+
from metatensor.torch import Labels, TensorMap
87
from metatomic.torch import (
98
AtomisticModel,
109
ModelCapabilities,
@@ -28,7 +27,6 @@
2827
from ._base_composition import (
2928
BaseCompositionModel,
3029
FixedCompositionWeights,
31-
_include_key,
3230
)
3331
from .documentation import ModelHypers
3432

@@ -46,7 +44,7 @@ class CompositionModel(ModelInterface[ModelHypers]):
4644
model.
4745
"""
4846

49-
__checkpoint_version__ = 1
47+
__checkpoint_version__ = 2
5048
__supported_devices__ = ["cuda", "cpu"]
5149
__supported_dtypes__ = [torch.float64]
5250
__default_metadata__ = ModelMetadata(
@@ -266,9 +264,8 @@ def restart(self, dataset_info: DatasetInfo) -> "CompositionModel":
266264
self.dataset_info = merged_info
267265

268266
self._new_outputs = []
269-
buffer_names = [n for n, _ in self.named_buffers()]
270267
for target_name, target_info in self.target_infos.items():
271-
if target_name + "_composition_buffer" in buffer_names:
268+
if target_name in self.model.weights:
272269
continue
273270
self._new_outputs.append(target_name)
274271
self.model.add_output(target_name, target_info.layout)
@@ -298,11 +295,6 @@ def forward(
298295
:return: A dictionary mapping each requested output name to the
299296
corresponding ``TensorMap`` containing the computed values.
300297
"""
301-
dtype = systems[0].positions.dtype
302-
device = systems[0].positions.device
303-
304-
self.weights_to(device, dtype)
305-
306298
for output_name in outputs.keys():
307299
if output_name not in self.outputs:
308300
raise ValueError(
@@ -349,43 +341,6 @@ def _add_output(self, target_name: str, target_info: TargetInfo) -> None:
349341
description=target_info.description,
350342
)
351343

352-
layout = mts.filter_blocks(
353-
target_info.layout,
354-
Labels(
355-
target_info.layout.keys.names,
356-
torch.vstack(
357-
[key.values for key in target_info.layout.keys if _include_key(key)]
358-
),
359-
assume_unique=True,
360-
),
361-
)
362-
363-
fake_weights = TensorMap(
364-
keys=layout.keys,
365-
blocks=[
366-
TensorBlock(
367-
values=torch.zeros(
368-
(len(self.atomic_types),) + b.values.shape[1:],
369-
dtype=torch.float64,
370-
),
371-
samples=Labels(
372-
names=["center_type"],
373-
values=torch.tensor(self.atomic_types, dtype=torch.int).reshape(
374-
-1, 1
375-
),
376-
assume_unique=True,
377-
),
378-
components=b.components,
379-
properties=b.properties,
380-
)
381-
for b in layout.blocks()
382-
],
383-
)
384-
self.register_buffer(
385-
target_name + "_composition_buffer",
386-
mts.save_buffer(mts.make_contiguous(fake_weights)),
387-
)
388-
389344
def remove_output(self, target_name: str) -> None:
390345
"""
391346
Remove a previously registered output target, mirroring ``_add_output``.
@@ -395,32 +350,6 @@ def remove_output(self, target_name: str) -> None:
395350
self.outputs.pop(target_name, None)
396351
self.dataset_info.targets.pop(target_name, None)
397352
self.model.remove_output(target_name)
398-
buffer_name = target_name + "_composition_buffer"
399-
if hasattr(self, buffer_name):
400-
delattr(self, buffer_name)
401-
402-
def weights_to(self, device: torch.device, dtype: torch.dtype) -> None:
403-
"""
404-
Move the fitted weights and the accumulated quantities to the given
405-
device and dtype.
406-
407-
Needed because they are stored as ``TensorMap`` attributes, which
408-
``torch.nn.Module.to`` does not move.
409-
410-
:param device: Device to move the weights to.
411-
:param dtype: Dtype to convert the weights to.
412-
"""
413-
if len(self.model.weights) != 0:
414-
if self.model.weights[list(self.model.weights.keys())[0]].device != device:
415-
self.model.weights = {
416-
k: v.to(device) for k, v in self.model.weights.items()
417-
}
418-
if self.model.weights[list(self.model.weights.keys())[0]].dtype != dtype:
419-
self.model.weights = {
420-
k: v.to(dtype) for k, v in self.model.weights.items()
421-
}
422-
423-
self.model._sync_device_dtype(device, dtype)
424353

425354
@staticmethod
426355
def is_valid_target(target_name: str, target_info: TargetInfo) -> bool:
@@ -472,21 +401,6 @@ def is_valid_target(target_name: str, target_info: TargetInfo) -> bool:
472401

473402
return True
474403

475-
def sync_tensor_maps(self) -> None:
476-
"""
477-
Reload the weight ``TensorMap`` objects from the registered buffers.
478-
479-
Must be called after the buffers change through means that bypass the
480-
model, e.g. ``load_state_dict``.
481-
"""
482-
for k in self.dataset_info.targets:
483-
buffer = self.__getattr__(k + "_composition_buffer")
484-
# ``mts.load_buffer`` dereferences the buffer on the host, so it
485-
# segfaults on a GPU buffer: deserialize on the CPU and move the
486-
# weights back to the buffer's device.
487-
weights = mts.load_buffer(buffer.to(device="cpu"))
488-
self.model.weights[k] = weights.to(device=buffer.device)
489-
490404
def get_checkpoint(self) -> Dict:
491405
"""
492406
Get the checkpoint of the model.
@@ -543,7 +457,6 @@ def load_checkpoint(
543457
)
544458

545459
model.load_state_dict(model_state_dict)
546-
model.sync_tensor_maps()
547460

548461
model.metadata = merge_metadata(model.metadata, checkpoint.get("metadata"))
549462

@@ -587,14 +500,11 @@ def export(self, metadata: Optional[ModelMetadata] = None) -> AtomisticModel:
587500
raise ValueError(f"unsupported dtype {dtype} for composition model")
588501

589502
self.to(dtype)
590-
self.weights_to(torch.device("cpu"), torch.float64)
591-
592-
interaction_range = 0.0
593503

594504
capabilities = ModelCapabilities(
595505
outputs=self.outputs,
596506
atomic_types=self.atomic_types,
597-
interaction_range=interaction_range,
507+
interaction_range=0.0,
598508
length_unit=self.dataset_info.length_unit,
599509
supported_devices=self.__supported_devices__,
600510
dtype=dtype_to_str(dtype),
Binary file not shown.

src/metatrain/composition/tests/test_basic.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ class TestCheckpoints(CheckpointTests, CompositionTests):
7777
incompatible_trainer_checkpoints = [
7878
"checkpoints/model-v1_trainer-v1.ckpt.gz",
7979
"checkpoints/model-v1_trainer-v2.ckpt.gz",
80+
"checkpoints/model-v2_trainer-v2.ckpt.gz",
8081
]
8182

8283

src/metatrain/composition/tests/test_regression.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,7 @@ def test_checkpoint_roundtrip_predictions():
236236
def test_regression_checkpoint():
237237
with gzip.open("regression_checkpoint.ckpt.gz", "rb") as fd:
238238
checkpoint = torch.load(fd, weights_only=False)
239+
checkpoint = CompositionModel.upgrade_checkpoint(checkpoint)
239240
model = CompositionModel.load_checkpoint(checkpoint, context="export")
240241
model.eval()
241242

src/metatrain/composition/trainer.py

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
from pathlib import Path
44
from typing import Any, Dict, List, Literal, Union
55

6-
import metatensor.torch as mts
76
import torch
87

98
from metatrain.utils.abc import ModelInterface, TrainerInterface
@@ -80,7 +79,8 @@ def train(
8079
else:
8180
device = devices[0]
8281
logging.info(f"Training on device {device} with dtype {dtype}")
83-
model.to(device=device)
82+
83+
model.to(device=device, dtype=torch.float64)
8484

8585
# Targets with fixed weights don't need data accumulation, only fit().
8686
targets_to_accumulate = [
@@ -190,7 +190,7 @@ def train(
190190
# A rank whose shard of some dataset was empty never accumulated
191191
# the corresponding targets, so its XTX/XTY are still on the CPU,
192192
# while NCCL needs them on the GPU for the all_reduce.
193-
model.model._sync_device_dtype(device, torch.float64)
193+
model.to(device=device, dtype=torch.float64)
194194
handles = []
195195
for target_name in targets_to_accumulate:
196196
for XTX_block, XTY_block in zip(
@@ -209,16 +209,6 @@ def train(
209209

210210
model.model.fit(fixed_weights, targets_to_fit=model._new_outputs)
211211

212-
for target_name in model.model.weights.keys():
213-
model.register_buffer(
214-
target_name + "_composition_buffer",
215-
mts.save_buffer(
216-
mts.make_contiguous(
217-
model.model.weights[target_name].to("cpu", torch.float64)
218-
)
219-
).to(device),
220-
)
221-
222212
if checkpoint_dir and (not is_distributed or torch.distributed.get_rank() == 0):
223213
ckpt_path = Path(checkpoint_dir) / "composition_model.ckpt"
224214
self.save_checkpoint(model, ckpt_path)

src/metatrain/experimental/dpa3/model.py

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -463,8 +463,6 @@ def load_checkpoint(
463463
dtype = next(model.model.parameters()).dtype
464464

465465
model.to(dtype).load_state_dict(model_state_dict)
466-
model.additive_models[0].sync_tensor_maps()
467-
model.scaler.sync_tensor_maps()
468466

469467
# Loading the metadata from the checkpoint
470468
metadata = checkpoint.get("metadata", None)
@@ -483,11 +481,6 @@ def export(self, metadata: Optional[ModelMetadata] = None) -> AtomisticModel:
483481
# float64
484482
self.to(dtype)
485483

486-
# Additionally, the composition model contains some `TensorMap`s that cannot
487-
# be registered correctly with Pytorch. This function moves them:
488-
489-
self.additive_models[0].weights_to(torch.device("cpu"), torch.float64)
490-
491484
interaction_ranges = [self.hypers["descriptor"]["repflow"]["e_rcut"]]
492485
for additive_model in self.additive_models:
493486
if hasattr(additive_model, "cutoff_radius"):

0 commit comments

Comments
 (0)