Skip to content

Commit 7cd3800

Browse files
zulissimetapre-commit-ci[bot]vineetbansalAndrew-S-Rosen
authored
Fairchem ray serve (#3228)
## Summary of Changes Adds an option to use a batching and distributed inference system for all fairchem MLIP inference when 1. Running the jobs/flows within a ray server and 2. When FAIRCHEM_RAY_SERVE_BATCHING quacc setting is true Relies on fairchem>=2.21 ### Requirements - [x] My PR is focused on a [single feature addition or bugfix](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/getting-started/best-practices-for-pull-requests#write-small-prs). - [x] My PR has relevant, comprehensive [unit tests](https://quantum-accelerators.github.io/quacc/dev/contributing.html#unit-tests). - [x] My PR is on a [custom branch](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-and-deleting-branches-within-your-repository) (i.e. is _not_ named `main`). Note: If you are an external contributor, you will see a comment from [@buildbot-princeton](https://github.com/buildbot-princeton). This is solely for the maintainers. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Vineet Bansal <vineetbansal@protonmail.com> Co-authored-by: Andrew S. Rosen <asrosen93@gmail.com>
1 parent 62d5f7d commit 7cd3800

7 files changed

Lines changed: 435 additions & 6 deletions

File tree

.github/workflows/tests.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -403,7 +403,7 @@ jobs:
403403
- name: Install pip packages
404404
run: |
405405
pip install uv
406-
uv pip install --system -r tests/requirements.txt -r tests/requirements-fairchem.txt "quacc[dev] @ ."
406+
uv pip install --system -r tests/requirements.txt -r tests/requirements-fairchem.txt -r tests/requirements-ray.txt "quacc[dev] @ ."
407407
408408
- name: Set coverage core
409409
run: |

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ dependencies = [
4343
[project.optional-dependencies]
4444
dask = ["dask[distributed]>=2023.12.1", "dask-jobqueue>=0.8.2"]
4545
defects = ["pymatgen-analysis-defects>=2024.10.22", "shakenbreak>=3.2.0"]
46-
fairchem = ["fairchem-data-omat>=0.2", "fairchem-data-oc>=1.0.2", "fairchem-core>=2.2.0"]
46+
fairchem = ["fairchem-data-omat>=0.2", "fairchem-data-oc>=1.0.2", "fairchem-core>=2.21.0"]
4747
jobflow = ["jobflow>=0.3.0", "jobflow-remote>=1.0.0"]
4848
orb = ["orb-models>=0.4.1"]
4949
mace = ["mace-torch>=0.3.3", "mace-models>=0.1.6"]

src/quacc/recipes/mlp/_base.py

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,11 +82,16 @@ def pick_calculator(
8282
"""
8383
import torch
8484

85-
if not torch.cuda.is_available():
86-
LOGGER.warning("CUDA is not available to PyTorch. Calculations will be slow.")
85+
from quacc import get_settings
8786

87+
settings = get_settings()
8888
method = method.lower()
8989

90+
# Skip CUDA warning for rayserve batching mode (inference happens on remote GPU)
91+
use_ray_serve = method == "fairchem" and settings.FAIRCHEM_RAY_SERVE_BATCHING
92+
if not use_ray_serve and not torch.cuda.is_available():
93+
LOGGER.warning("CUDA is not available to PyTorch. Calculations will be slow.")
94+
9095
if "m3gnet" in method or "chgnet" in method or "tensornet" in method:
9196
import matgl
9297
from matgl import __version__
@@ -132,7 +137,67 @@ def pick_calculator(
132137
elif method.lower() == "fairchem":
133138
from fairchem.core import FAIRChemCalculator, __version__
134139

135-
calc = FAIRChemCalculator.from_model_checkpoint(**calc_kwargs)
140+
# Check if Ray Serve batching is enabled AND Ray is actually available
141+
if use_ray_serve:
142+
try:
143+
import ray
144+
145+
if not ray.is_initialized():
146+
LOGGER.warning(
147+
"FAIRCHEM_RAY_SERVE_BATCHING is enabled but Ray is not initialized. "
148+
"Falling back to local inference. To use Ray Serve batching, ensure "
149+
"your flow is running with a Ray cluster (e.g., SlurmRayTaskRunner with "
150+
"start_inference_server=True)."
151+
)
152+
use_ray_serve = False
153+
except ImportError:
154+
LOGGER.warning(
155+
"FAIRCHEM_RAY_SERVE_BATCHING is enabled but Ray is not installed. "
156+
"Falling back to local inference."
157+
)
158+
use_ray_serve = False
159+
160+
if use_ray_serve:
161+
# Use Ray Serve multiplexed deployment for inference. The deployment
162+
# is expected to already be running on the cluster (typically started
163+
# by get_local_inference_raycluster / get_slurm_inference_raycluster with
164+
# setup_multiplexed_batch_predict_server). We connect by deployment
165+
# name and route requests to the appropriate model via the
166+
# multiplexed_model_id, which has the form
167+
# "<checkpoint_name_or_path>:<inference_settings>".
168+
from fairchem.core.units.mlip_unit.predict import BatchServerPredictUnit
169+
170+
calc_kwargs = calc_kwargs.copy() # Don't modify the original kwargs
171+
172+
# Determine model identifier: prefer name_or_path (local checkpoint)
173+
# over model_id/checkpoint
174+
name_or_path = calc_kwargs.pop("name_or_path", None)
175+
if name_or_path is not None:
176+
checkpoint_id = str(name_or_path)
177+
else:
178+
checkpoint_id = calc_kwargs.pop("model_id", None) or calc_kwargs.pop(
179+
"checkpoint", "uma-s-1p1"
180+
)
181+
182+
inference_settings = calc_kwargs.pop("inference_settings", "default")
183+
task_name = calc_kwargs.pop("task_name")
184+
185+
# Drop kwargs only meaningful when loading the checkpoint locally
186+
calc_kwargs.pop("device", None)
187+
calc_kwargs.pop("overrides", None)
188+
calc_kwargs.pop("seed", None)
189+
190+
multiplexed_model_id = f"{checkpoint_id}:{inference_settings}"
191+
192+
mlip_unit = BatchServerPredictUnit.from_deployment_connection_info(
193+
deployment_name="multiplexed-predict-server",
194+
multiplexed_model_id=multiplexed_model_id,
195+
)
196+
197+
calc = FAIRChemCalculator(predict_unit=mlip_unit, task_name=task_name)
198+
else:
199+
# Use local inference
200+
calc = FAIRChemCalculator.from_model_checkpoint(**calc_kwargs)
136201

137202
else:
138203
raise ValueError(f"Unrecognized {method=}.")

src/quacc/settings.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,23 @@ class QuaccSettings(BaseSettings):
126126
description="Whether to resolve all futures in flow results to data and fail if not possible",
127127
)
128128

129+
# ---------------------------
130+
# FAIRChem Settings
131+
# ---------------------------
132+
FAIRCHEM_RAY_SERVE_BATCHING: bool = Field(
133+
False,
134+
description=(
135+
"""
136+
Whether to use Ray Serve for batched FAIRChem inference. When True,
137+
the 'fairchem' method in pick_calculator will use RayServeMLIPUnit
138+
to send inference requests to a Ray Serve deployment instead of
139+
running inference locally. This enables efficient batched inference
140+
across multiple concurrent calculations. Requires a Ray Serve inference
141+
server to be running (e.g., via SlurmRayTaskRunner with start_inference_server=True).
142+
"""
143+
),
144+
)
145+
129146
# ---------------------------
130147
# ORCA Settings
131148
# ---------------------------
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
from __future__ import annotations
2+
3+
from importlib.util import find_spec
4+
5+
import pytest
6+
7+
torch = pytest.importorskip("torch")
8+
pytest.importorskip("ray")
9+
pytest.importorskip("fairchem.core")
10+
11+
if find_spec("fairchem"):
12+
from huggingface_hub.utils._auth import get_token
13+
14+
if not get_token():
15+
pytest.skip(
16+
"HF_TOKEN required for fairchem ray-serve tests", allow_module_level=True
17+
)
18+
19+
import numpy as np
20+
from ase.build import bulk
21+
22+
from quacc import get_settings
23+
from quacc.recipes.mlp._base import pick_calculator
24+
from quacc.recipes.mlp.core import relax_job, static_job
25+
from quacc.recipes.mlp.elastic import elastic_tensor_flow
26+
27+
28+
@pytest.fixture(scope="module")
29+
def ray_serve_cluster():
30+
"""Spin up a local Ray cluster + multiplexed fairchem serve deployment.
31+
32+
The deployment is named ``predict-server`` to match what
33+
``quacc.recipes.mlp._base`` connects to when
34+
``FAIRCHEM_RAY_SERVE_BATCHING`` is enabled.
35+
"""
36+
import ray
37+
from fairchem.core.components.batch_server import (
38+
setup_multiplexed_batch_predict_server,
39+
)
40+
from ray import serve
41+
42+
started_ray = False
43+
if not ray.is_initialized():
44+
ray.init(
45+
include_dashboard=False,
46+
ignore_reinit_error=True,
47+
log_to_driver=False,
48+
configure_logging=False,
49+
)
50+
started_ray = True
51+
52+
handle = setup_multiplexed_batch_predict_server(
53+
deployment_name="predict-server",
54+
route_prefix="/predict-server",
55+
num_replicas=1,
56+
ray_actor_options={"num_cpus": 1},
57+
)
58+
try:
59+
yield handle
60+
finally:
61+
try:
62+
serve.shutdown()
63+
finally:
64+
if started_ray and ray.is_initialized():
65+
ray.shutdown()
66+
67+
68+
@pytest.fixture
69+
def _batching(monkeypatch, request, ray_serve_cluster):
70+
settings = get_settings()
71+
monkeypatch.setattr(
72+
settings, "FAIRCHEM_RAY_SERVE_BATCHING", request.param, raising=False
73+
)
74+
# pick_calculator is wrapped by freezeargs(lru_cache(...)); clear the
75+
# underlying lru_cache so each parametrize value rebuilds the calc
76+
# under the current FAIRCHEM_RAY_SERVE_BATCHING setting.
77+
pick_calculator.__wrapped__.cache_clear()
78+
yield request.param
79+
pick_calculator.__wrapped__.cache_clear()
80+
81+
82+
def _assert_calc_path(batching):
83+
"""Verify the calculator built during the test actually used the
84+
expected code path (Ray Serve batching vs. local inference)."""
85+
from fairchem.core.units.mlip_unit.predict import BatchServerPredictUnit
86+
87+
calc = pick_calculator(
88+
method="fairchem", name_or_path="uma-s-1p1", task_name="omat"
89+
)
90+
used_batching = isinstance(getattr(calc, "predictor", None), BatchServerPredictUnit)
91+
assert used_batching is batching, (
92+
f"Expected FAIRCHEM_RAY_SERVE_BATCHING={batching} path, "
93+
f"but BatchServerPredictUnit usage was {used_batching}."
94+
)
95+
96+
97+
@pytest.mark.parametrize("_batching", [False, True], indirect=True)
98+
def test_static_job(tmp_path, monkeypatch, _batching):
99+
monkeypatch.chdir(tmp_path)
100+
torch.set_default_dtype(torch.float32)
101+
102+
atoms = bulk("Cu")
103+
output = static_job(
104+
atoms, method="fairchem", name_or_path="uma-s-1p1", task_name="omat"
105+
)
106+
assert output["results"]["energy"] == pytest.approx(-3.7501682869643735, rel=1e-3)
107+
assert np.shape(output["results"]["forces"]) == (1, 3)
108+
assert output["atoms"] == atoms
109+
_assert_calc_path(_batching)
110+
111+
112+
@pytest.mark.parametrize("_batching", [False, True], indirect=True)
113+
def test_relax_job(tmp_path, monkeypatch, _batching):
114+
monkeypatch.chdir(tmp_path)
115+
torch.set_default_dtype(torch.float32)
116+
117+
atoms = bulk("Cu") * (2, 2, 2)
118+
atoms[0].position += 0.1
119+
output = relax_job(
120+
atoms, method="fairchem", name_or_path="uma-s-1p1", task_name="omat"
121+
)
122+
assert output["results"]["energy"] == pytest.approx(-30.001143639922756, rel=1e-3)
123+
assert np.shape(output["results"]["forces"]) == (8, 3)
124+
assert output["atoms"] != atoms
125+
assert output["atoms"].get_volume() == pytest.approx(atoms.get_volume())
126+
_assert_calc_path(_batching)
127+
128+
129+
@pytest.mark.parametrize("_batching", [False, True], indirect=True)
130+
def test_relax_cell_job(tmp_path, monkeypatch, _batching):
131+
monkeypatch.chdir(tmp_path)
132+
torch.set_default_dtype(torch.float32)
133+
134+
atoms = bulk("Cu") * (2, 2, 2)
135+
atoms[0].position += 0.1
136+
output = relax_job(
137+
atoms,
138+
method="fairchem",
139+
relax_cell=True,
140+
name_or_path="uma-s-1p1",
141+
task_name="omat",
142+
)
143+
assert output["results"]["energy"] == pytest.approx(-30.005004590392726, rel=1e-3)
144+
assert np.shape(output["results"]["forces"]) == (8, 3)
145+
assert output["atoms"] != atoms
146+
assert output["atoms"].get_volume() != pytest.approx(atoms.get_volume())
147+
_assert_calc_path(_batching)
148+
149+
150+
@pytest.mark.parametrize("_batching", [False, True], indirect=True)
151+
def test_elastic_jobs(tmp_path, monkeypatch, _batching):
152+
monkeypatch.chdir(tmp_path)
153+
torch.set_default_dtype(torch.float32)
154+
155+
atoms = bulk("Cu")
156+
outputs = elastic_tensor_flow(
157+
atoms,
158+
run_static=False,
159+
pre_relax=True,
160+
job_params={
161+
"all": {
162+
"method": "fairchem",
163+
"name_or_path": "uma-s-1p1",
164+
"task_name": "omat",
165+
},
166+
"relax_job": {"opt_params": {"fmax": 0.01}},
167+
},
168+
)
169+
assert outputs["deformed_results"][0]["atoms"].get_volume() != pytest.approx(
170+
atoms.get_volume()
171+
)
172+
assert outputs["undeformed_result"]["results"]["stress"] == pytest.approx(
173+
0, abs=1e-2
174+
)
175+
assert outputs["elasticity_doc"].bulk_modulus.voigt == pytest.approx(151.367, abs=2)
176+
_assert_calc_path(_batching)

0 commit comments

Comments
 (0)