Skip to content

Commit a9880ee

Browse files
committed
Generate and persist UMA model IDs
1 parent a3fa357 commit a9880ee

9 files changed

Lines changed: 238 additions & 21 deletions

File tree

CLAUDE.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,11 @@ configs/ # Hydra YAML configs (datasets, tasks, backbone, optimi
332332
not depend on enumeration order, seeded random coordinates, or atom counts
333333
unless those values are part of the public contract; prefer composition,
334334
Miller index, shift, placement, and cell invariants that survive upgrades.
335+
- Do not make `HydraModel.model_id` unconditionally required. Older non-UMA
336+
Hydra checkpoints are intentionally untagged, while new UMA MoE configs get
337+
a generated ID. Generate it on rank zero after distributed setup, broadcast
338+
it to every rank, and explicitly persist it in the resume/DCP model config;
339+
setting only the model attribute does not make it part of the checkpoint.
335340
336341
Anytime we learn something that could be beneficial in future coding sessions, automatically add it to CLAUDE.md.
337342

configs/uma/benchmark/perf_check/training_inner.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ runner:
5454
tasks: ${tasks}
5555
model:
5656
_target_: fairchem.core.models.base.HydraModel
57+
model_id: UMA-S-1.2
5758
backbone: ${backbone}
5859
heads: ${heads}
5960
pass_through_head_outputs: True

configs/uma/benchmark/perf_check/training_inner_2x.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ runner:
5454
tasks: ${tasks}
5555
model:
5656
_target_: fairchem.core.models.base.HydraModel
57+
model_id: UMA-S-1.2
5758
backbone: ${backbone}
5859
heads: ${heads}
5960
pass_through_head_outputs: True

src/fairchem/core/models/base.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from fairchem.core.common.utils import (
2222
load_model_and_weights_from_checkpoint,
2323
)
24+
from fairchem.core.models.uma.compat import ensure_uma_model_id
2425

2526
if TYPE_CHECKING:
2627
from collections.abc import Sequence
@@ -328,6 +329,11 @@ def __init__(
328329
self._tasks = None
329330
self._dataset_to_tasks = None
330331

332+
model_config = {"backbone": backbone, "model_id": model_id}
333+
generated_model_id = ensure_uma_model_id(model_config)
334+
if generated_model_id is not None:
335+
model_id = generated_model_id
336+
331337
# Does this model support inference on single atom systems
332338
self.supports_single_atoms = supports_single_atoms
333339
# model_id string in form of NAME-VERSION e.g. UMA-1.2

src/fairchem/core/models/uma/compat.py

Lines changed: 70 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,12 @@
1616

1717
from __future__ import annotations
1818

19-
from typing import TYPE_CHECKING, Literal
19+
import logging
20+
from collections.abc import Mapping
21+
from typing import TYPE_CHECKING, Literal, MutableMapping
22+
from uuid import uuid4
23+
24+
from fairchem.core.common import distutils
2025

2126
if TYPE_CHECKING:
2227
from fairchem.core.units.mlip_unit.api.inference import MLIPInferenceCheckpoint
@@ -37,7 +42,68 @@
3742
_UMA_BACKBONE_FQN_SUFFIX = "uma.escn_moe.eSCNMDMoeBackbone"
3843

3944

40-
def get_uma_version(model_config: dict | None) -> UmaVersion:
45+
def is_uma_moe_backbone_config(backbone_config: Mapping | None) -> bool:
46+
"""
47+
Return whether a backbone config describes an UMA MoE model.
48+
49+
The UMA MoE backbone is also shared by models such as eSEN with
50+
``num_experts == 0``. Those models do not have model-ID-gated behavior and
51+
therefore do not require a ``model_id``.
52+
53+
Args:
54+
backbone_config: Backbone configuration to classify.
55+
56+
Returns:
57+
Whether the configuration describes an UMA backbone with experts.
58+
"""
59+
if not isinstance(backbone_config, Mapping):
60+
return False
61+
62+
model = backbone_config.get("model")
63+
if not isinstance(model, str) or not (
64+
model == _UMA_BACKBONE_SHORT_NAME or model.endswith(_UMA_BACKBONE_FQN_SUFFIX)
65+
):
66+
return False
67+
68+
num_experts = backbone_config.get("num_experts")
69+
return isinstance(num_experts, int) and num_experts > 0
70+
71+
72+
def ensure_uma_model_id(model_config: MutableMapping) -> str | None:
73+
"""
74+
Add a generated ID to an untagged UMA MoE model config.
75+
76+
Existing IDs are preserved, and non-UMA model configs are unchanged.
77+
78+
Args:
79+
model_config: Model configuration to update in place.
80+
81+
Returns:
82+
The existing or generated UMA model ID, or ``None`` for non-UMA models.
83+
"""
84+
if not is_uma_moe_backbone_config(model_config.get("backbone")):
85+
return None
86+
87+
model_id = model_config.get("model_id")
88+
if isinstance(model_id, str) and model_id.strip():
89+
return model_id
90+
91+
model_id = f"UMA-{uuid4().hex[:12]}" if distutils.is_master() else None
92+
model_id_list = [model_id]
93+
distutils.broadcast_object_list(model_id_list, src=0)
94+
model_id = model_id_list[0]
95+
if not isinstance(model_id, str):
96+
raise RuntimeError("Failed to broadcast the generated UMA model_id")
97+
model_config["model_id"] = model_id
98+
if distutils.is_master():
99+
logging.warning(
100+
"No model_id was provided for an UMA MoE model. Generated model_id=%r.",
101+
model_id,
102+
)
103+
return model_id
104+
105+
106+
def get_uma_version(model_config: Mapping | None) -> UmaVersion:
41107
"""Classify what fix-up a checkpoint needs (see :func:`apply_uma_compat_fixups`).
42108
43109
* ``"not_uma"`` — not a UMA MoE backbone. This includes non-UMA models and
@@ -50,20 +116,10 @@ def get_uma_version(model_config: dict | None) -> UmaVersion:
50116
* ``"tagged"`` — already has a ``model_id`` (UMA 1.2+ or custom) → no-op. The
51117
1.2 ``include_self`` rule lives in the backbone, keyed on ``model_id``.
52118
"""
53-
if not isinstance(model_config, dict):
119+
if not isinstance(model_config, Mapping):
54120
return "not_uma"
55121
backbone = model_config.get("backbone", {})
56-
if not isinstance(backbone, dict):
57-
return "not_uma"
58-
model = backbone.get("model")
59-
if not isinstance(model, str) or not (
60-
model == _UMA_BACKBONE_SHORT_NAME or model.endswith(_UMA_BACKBONE_FQN_SUFFIX)
61-
):
62-
return "not_uma"
63-
64-
# UMA uses num_experts > 0; eSCNMDMoeBackbone with num_experts == 0
65-
# (e.g. eSEN) is not UMA.
66-
if not isinstance(backbone.get("num_experts"), int) or backbone["num_experts"] == 0:
122+
if not is_uma_moe_backbone_config(backbone):
67123
return "not_uma"
68124

69125
model_id = model_config.get("model_id")

src/fairchem/core/units/mlip_unit/mlip_unit.py

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@
1212
import time
1313
from copy import deepcopy
1414
from dataclasses import dataclass, field
15-
from typing import TYPE_CHECKING, Any, Optional, Sequence
15+
from typing import Any, Optional, Sequence
1616

1717
import numpy as np
1818
import torch
1919
import torch.distributed.checkpoint as dcp
20-
from omegaconf import OmegaConf
20+
from omegaconf import DictConfig, OmegaConf, open_dict
2121
from torch.distributed.checkpoint.format_utils import dcp_to_torch_save
2222
from torch.distributed.checkpoint.state_dict import (
2323
get_model_state_dict,
@@ -62,12 +62,45 @@
6262
tf32_context_manager,
6363
)
6464

65-
if TYPE_CHECKING:
66-
from omegaconf import DictConfig
67-
6865
# this is a config generated on the fly and can be used to resume a run for a given checkpoint
6966
UNIT_RESUME_CONFIG = "resume.yaml"
7067

68+
69+
def _get_train_eval_unit_config(config: dict | DictConfig):
70+
"""
71+
Return the train/eval unit config from direct or Ray-wrapped runner config.
72+
73+
Args:
74+
config: Canonical job configuration.
75+
76+
Returns:
77+
The train/eval unit configuration.
78+
"""
79+
runner_config = config["runner"]
80+
if "train_eval_unit" not in runner_config:
81+
runner_config = runner_config["runner_config"]
82+
return runner_config["train_eval_unit"]
83+
84+
85+
def _set_model_id_in_config(config: dict | DictConfig, model_id: str | None) -> None:
86+
"""
87+
Persist a resolved model ID in a canonical training configuration.
88+
89+
Args:
90+
config: Canonical job configuration to update.
91+
model_id: Resolved model ID, or ``None`` for models without one.
92+
"""
93+
if model_id is None:
94+
return
95+
96+
model_config = _get_train_eval_unit_config(config)["model"]
97+
if isinstance(model_config, DictConfig):
98+
with open_dict(model_config):
99+
model_config["model_id"] = model_id
100+
else:
101+
model_config["model_id"] = model_id
102+
103+
71104
# this represents the inference only checkpoint generated at each checkpoint
72105
UNIT_INFERENCE_CHECKPOINT = "inference_ckpt.pt"
73106

@@ -126,7 +159,7 @@ def convert_train_checkpoint_to_inference_checkpoint(
126159
inference_ckpt = torch.load(
127160
checkpoint_loc, map_location="cpu", weights_only=False
128161
) # DCP model config
129-
train_eval_unit_state = inference_ckpt["config"]["runner"]["train_eval_unit"]
162+
train_eval_unit_state = _get_train_eval_unit_config(inference_ckpt["config"])
130163
unit_state = inference_ckpt["unit_state"]
131164
torch.save(
132165
MLIPInferenceCheckpoint(
@@ -535,6 +568,7 @@ def __init__(
535568
self.finetune_model_full_config = getattr(
536569
model, "finetune_model_full_config", None
537570
)
571+
self.model_id = getattr(model, "model_id", None)
538572
# call optimizer function between wrapping in DDP
539573
# this is required for models that have a no_weight_decay function
540574
self.optimizer = _get_optimizer_wd(optimizer_fn, model)
@@ -888,7 +922,10 @@ def save_state(self, checkpoint_location: str) -> None:
888922

889923
finetune_model_full_config = self.get_finetune_model_config()
890924
if finetune_model_full_config is not None:
891-
config.runner.train_eval_unit.model = finetune_model_full_config
925+
train_eval_unit_config = _get_train_eval_unit_config(config)
926+
train_eval_unit_config["model"] = finetune_model_full_config
927+
928+
_set_model_id_in_config(config, self.model_id)
892929

893930
OmegaConf.save(config, os.path.join(checkpoint_location, UNIT_RESUME_CONFIG))
894931

tests/core/models/test_base.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""
2+
Copyright (c) Meta Platforms, Inc. and affiliates.
3+
4+
This source code is licensed under the MIT license found in the
5+
LICENSE file in the root directory of this source tree.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import logging
11+
import re
12+
13+
import pytest
14+
15+
from fairchem.core.common.registry import registry
16+
from fairchem.core.models.base import HydraModel
17+
18+
19+
@pytest.mark.parametrize("model_id", [None, "", " "])
20+
def test_uma_moe_hydra_model_generates_model_id(model_id, caplog, monkeypatch):
21+
backbone = {
22+
"model": "fairchem.core.models.uma.escn_moe.eSCNMDMoeBackbone",
23+
"num_experts": 8,
24+
}
25+
26+
class DummyBackbone:
27+
def __init__(self, **kwargs):
28+
pass
29+
30+
monkeypatch.setattr(registry, "get_model_class", lambda _: DummyBackbone)
31+
with caplog.at_level(logging.WARNING):
32+
model = HydraModel(backbone=backbone, heads={}, model_id=model_id)
33+
34+
assert re.fullmatch(r"UMA-[0-9a-f]{12}", model.model_id)
35+
assert model.backbone.model_id == model.model_id
36+
assert f"Generated model_id='{model.model_id}'" in caplog.text

tests/core/models/uma/test_compat.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,15 @@
55
from __future__ import annotations
66

77
import pytest
8+
from omegaconf import OmegaConf
89

10+
from fairchem.core.common import distutils
911
from fairchem.core.models.uma.compat import (
1012
UMA_1P1_MODEL_ID,
1113
apply_uma_compat_fixups,
14+
ensure_uma_model_id,
1215
get_uma_version,
16+
is_uma_moe_backbone_config,
1317
)
1418
from fairchem.core.units.mlip_unit.api.inference import MLIPInferenceCheckpoint
1519

@@ -40,6 +44,48 @@ def uma_cfg(
4044
return cfg
4145

4246

47+
@pytest.mark.parametrize("backbone_model", [UMA_BACKBONE_FQN, UMA_BACKBONE_SHORT])
48+
def test_uma_moe_backbone_config(backbone_model):
49+
assert is_uma_moe_backbone_config({"model": backbone_model, "num_experts": 8})
50+
51+
52+
def test_uma_moe_dict_config():
53+
config = OmegaConf.create(uma_cfg(model_id="UMA-S-1.2"))
54+
55+
assert get_uma_version(config) == "tagged"
56+
assert is_uma_moe_backbone_config(config.backbone)
57+
58+
59+
def test_existing_uma_model_id_is_preserved():
60+
config = uma_cfg(model_id="UMA-S-custom")
61+
62+
assert ensure_uma_model_id(config) == "UMA-S-custom"
63+
assert config["model_id"] == "UMA-S-custom"
64+
65+
66+
def test_generated_uma_model_id_is_broadcast(monkeypatch):
67+
config = uma_cfg()
68+
69+
monkeypatch.setattr(distutils, "is_master", lambda: False)
70+
71+
def broadcast_model_id(model_id_list, src):
72+
assert model_id_list == [None]
73+
assert src == 0
74+
model_id_list[0] = "UMA-from-rank-zero"
75+
76+
monkeypatch.setattr(distutils, "broadcast_object_list", broadcast_model_id)
77+
78+
assert ensure_uma_model_id(config) == "UMA-from-rank-zero"
79+
assert config["model_id"] == "UMA-from-rank-zero"
80+
81+
82+
@pytest.mark.parametrize("num_experts", [0, -1, None])
83+
def test_uma_non_moe_backbone_config(num_experts):
84+
assert not is_uma_moe_backbone_config(
85+
{"model": UMA_BACKBONE_FQN, "num_experts": num_experts}
86+
)
87+
88+
4389
# ---------------------------------------------------------------------------
4490
# UMA 1.0 / untagged (neither model_id nor model_version) — hard fail
4591
# ---------------------------------------------------------------------------
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""
2+
Copyright (c) Meta Platforms, Inc. and affiliates.
3+
4+
This source code is licensed under the MIT license found in the
5+
LICENSE file in the root directory of this source tree.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import pytest
11+
from omegaconf import OmegaConf
12+
13+
from fairchem.core.units.mlip_unit.mlip_unit import (
14+
_get_train_eval_unit_config,
15+
_set_model_id_in_config,
16+
)
17+
18+
19+
@pytest.mark.parametrize("ray_wrapped", [False, True])
20+
def test_set_model_id_in_checkpoint_config(ray_wrapped):
21+
train_runner = {"train_eval_unit": {"model": {"_target_": "model"}}}
22+
runner = {"runner_config": train_runner} if ray_wrapped else train_runner
23+
config = OmegaConf.create({"runner": runner})
24+
OmegaConf.set_struct(config, True)
25+
26+
_set_model_id_in_config(config, "UMA-generated")
27+
28+
train_eval_unit_config = _get_train_eval_unit_config(config)
29+
assert train_eval_unit_config.model.model_id == "UMA-generated"

0 commit comments

Comments
 (0)