| |
| import torch |
|
|
| from onescience.datapipes.materials.nequip import AtomicDataDict |
| from .graph_model import GraphModel |
| from ._graph_mixin import GraphModuleMixin |
| from onescience.utils.nequip.internal.dtype import ( |
| test_model_output_similarity_by_dtype, |
| _pt2_compile_error_message, |
| ) |
| from onescience.utils.nequip.internal.fx import nequip_make_fx |
| from onescience.utils.nequip.internal.dtype import dtype_to_name |
| from typing import Dict, Sequence, List, Optional, Any, Final |
| from torch.func import functional_call |
|
|
|
|
| def _list_to_dict( |
| keys: Sequence[str], args: List[torch.Tensor] |
| ) -> Dict[str, torch.Tensor]: |
| return {key: arg for key, arg in zip(keys, args)} |
|
|
|
|
| def _list_from_dict( |
| keys: Sequence[str], data: Dict[str, torch.Tensor] |
| ) -> List[torch.Tensor]: |
| return [data[key] for key in keys] |
|
|
|
|
| class ListInputOutputWrapper(torch.nn.Module): |
| """ |
| Wraps a ``torch.nn.Module`` that takes and returns ``Dict[str, torch.Tensor]`` to have it take and return ``Sequence[torch.Tensor]`` for specified input and output fields. |
| """ |
|
|
| def __init__( |
| self, |
| model: torch.nn.Module, |
| input_keys: Sequence[str], |
| output_keys: Sequence[str], |
| ): |
| super().__init__() |
| self.model = model |
| self.input_keys = list(input_keys) |
| self.output_keys = list(output_keys) |
|
|
| def forward(self, *args: torch.Tensor) -> List[torch.Tensor]: |
| inputs = _list_to_dict(self.input_keys, args) |
| outputs = self.model(inputs) |
| return _list_from_dict(self.output_keys, outputs) |
|
|
|
|
| class DictInputOutputWrapper(torch.nn.Module): |
| """ |
| Wraps a model that takes and returns ``Sequence[torch.Tensor]`` to have it take and return ``Dict[str, torch.Tensor]`` for specified input and output fields (i.e. the opposite of ``ListInputOutputWrapper``). |
| """ |
|
|
| def __init__(self, model, input_keys: List[str], output_keys: List[str]): |
| super().__init__() |
| self.model = model |
| self.input_keys = input_keys |
| self.output_keys = output_keys |
|
|
| def forward(self, data: AtomicDataDict.Type) -> AtomicDataDict.Type: |
| inputs = _list_from_dict(self.input_keys, data) |
| with torch.inference_mode(): |
| outputs = self.model(inputs) |
| return _list_to_dict(self.output_keys, outputs) |
|
|
|
|
| class ListInputOutputStateDictWrapper(ListInputOutputWrapper): |
| """Like ``ListInputOutputWrapper``, but also updates the model with state dict entries before each ``forward`` using ``functional_call``.""" |
|
|
| def __init__( |
| self, |
| model: torch.nn.Module, |
| input_keys: Sequence[str], |
| output_keys: Sequence[str], |
| state_dict_keys: Sequence[str], |
| ): |
| super().__init__(model, input_keys, output_keys) |
| self.state_dict_keys = state_dict_keys |
|
|
| def forward(self, *args: torch.Tensor) -> List[torch.Tensor]: |
| |
| input_dict = _list_to_dict(self.input_keys, args[: len(self.input_keys)]) |
| state_dict = _list_to_dict(self.state_dict_keys, args[len(self.input_keys) :]) |
| |
| output_dict = functional_call(self.model, state_dict, args=(input_dict,)) |
| return _list_from_dict(self.output_keys, output_dict) |
|
|
|
|
| class CompileGraphModel(GraphModel): |
| """Wrapper that uses ``torch.compile`` to optimize the wrapped module while allowing it to be trained. |
| |
| The cache is keyed by input signature (input keys only). |
| For each input signature, the eager model is run to determine the output keys, and then a compiled model is created for that input/output combination. |
| The compiled model and output keys are stored together in the cache. |
| """ |
|
|
| is_compile_graph_model: Final[bool] = True |
| |
|
|
| def __init__( |
| self, |
| model: GraphModuleMixin, |
| model_config: Optional[Dict[str, str]] = None, |
| model_input_fields: Dict[str, Any] = {}, |
| ) -> None: |
| super().__init__(model, model_config, model_input_fields) |
| |
| |
| |
| |
| self._compiled_cache = ({},) |
| |
| |
| self.weight_names = None |
| self.buffer_names = None |
|
|
| def _get_input_signature(self, data: AtomicDataDict.Type) -> tuple: |
| """Compute a hashable signature for the input keys. |
| |
| The unique set of input keys determines a unique set of output keys when run through the model, |
| so we only need the input keys for the cache lookup signature. |
| |
| Uses intersection of data keys and GraphModel inputs, which assumes: |
| - correctness of irreps registration system |
| - this particular batch contains all necessary inputs for this variant |
| """ |
| input_keys = tuple(sorted(data.keys() & self.model_input_fields)) |
| return input_keys |
|
|
| def forward(self, data: AtomicDataDict.Type) -> AtomicDataDict.Type: |
| |
| |
| |
| |
| |
| |
| |
| if ( |
| AtomicDataDict.num_nodes(data) < 2 |
| or AtomicDataDict.num_frames(data) < 2 |
| or AtomicDataDict.num_edges(data) < 2 |
| ): |
| |
| return super().forward(data) |
|
|
| |
| |
| input_signature = self._get_input_signature(data) |
| cache = self._compiled_cache[0] |
|
|
| if input_signature not in cache: |
| |
| if self.weight_names is None: |
| self.weight_names = [n for n, _ in self.model.named_parameters()] |
| self.buffer_names = [n for n, _ in self.model.named_buffers()] |
|
|
| |
| input_fields = list(input_signature) |
|
|
| |
| eager_output = super().forward(data.copy()) |
| output_fields = tuple(sorted(eager_output.keys())) |
| del eager_output |
|
|
| |
| model_to_trace = ListInputOutputStateDictWrapper( |
| model=self.model, |
| input_keys=input_fields, |
| output_keys=output_fields, |
| state_dict_keys=self.weight_names + self.buffer_names, |
| ) |
|
|
| weights, buffers = self._get_weights_buffers() |
| fx_model = nequip_make_fx( |
| model=model_to_trace, |
| data=data, |
| fields=input_fields, |
| extra_inputs=weights + buffers, |
| ) |
| del weights, buffers |
|
|
| |
| |
| |
| compiled_model = torch.compile( |
| fx_model, |
| dynamic=True, |
| fullgraph=False, |
| ) |
|
|
| |
| cache[input_signature] = (compiled_model, output_fields) |
|
|
| |
| def compiled_forward_for_test(data_test): |
| return self._compiled_forward( |
| data_test, compiled_model, input_fields, output_fields |
| ) |
|
|
| |
| test_fields = sorted(set(output_fields) & data.keys()) |
| test_model_output_similarity_by_dtype( |
| compiled_forward_for_test, |
| self.model, |
| {k: data[k] for k in input_fields}, |
| dtype_to_name(self.model_dtype), |
| fields=test_fields, |
| error_message=_pt2_compile_error_message, |
| ) |
|
|
| |
| compiled_model, output_fields = cache[input_signature] |
| out_dict = self._compiled_forward( |
| data, compiled_model, input_signature, output_fields |
| ) |
| to_return = data.copy() |
| to_return.update(out_dict) |
| return to_return |
|
|
| def _compiled_forward(self, data, compiled_model, input_fields, output_fields): |
| |
| weights, buffers = self._get_weights_buffers() |
| data_list = _list_from_dict(input_fields, data) |
| out_list = compiled_model(*(data_list + weights + buffers)) |
| out_dict = _list_to_dict(output_fields, out_list) |
| return out_dict |
|
|
| def _get_weights_buffers(self): |
| |
| weight_dict = dict(self.model.named_parameters()) |
| weights = [weight_dict[name] for name in self.weight_names] |
| buffer_dict = dict(self.model.named_buffers()) |
| buffers = [buffer_dict[name] for name in self.buffer_names] |
| return weights, buffers |
|
|