MACE Foundation Models#
The example exports the MACE-MH-1 foundation model and runs the exported model with the chemtrain-deploy LAMMPS package. The simulation uses a 10,000-molecule, 30,000-atom water box on two GPUs.
Prerequisites#
A CUDA chemtrain-deploy installation built with the optional OpenEquivariance extension.
chemtrainMACE 0.3.16 and MACE-JAX PR #21 at commit 594563b with OpenEquivariance support
ASE for writing the LAMMPS data file
A PDB file for the initial configuration. The example below uses
water_10k.pdbfromdocs/_data/.
Keep the downloaded MACE-MH-1 model intact and select its omol head during
conversion. In particular, do not rebuild it with remove_pt_head when using
MACE 0.3.16. The rebuilt model stores activation-normalization constants in a
form that the current
MACE-JAX pull request does not
fully import. Selecting the head on the original model avoids that conversion
error. Compare a few energies and forces with the PyTorch model whenever the
MACE or MACE-JAX version changes.
Overview#
The workflow has three steps:
Export — convert the PyTorch MACE-MH-1 weights to a chemtrain-deploy model bundle (
.ptb).Prepare — convert the PDB file to a LAMMPS data file.
Simulate — run the LAMMPS script against the exported model.
Step 1: Export the Model#
The export script loads MACE-MH-1 with the omol head and wraps the model in an
chemtrain.deploy.exporter.Exporter subclass. The bundle contains both
comm-off Newton variants and a communication-enabled Newton-on variant.
export_mace_mh1.py:
import argparse
import functools
import os
from pathlib import Path
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--reference_output", type=Path, required=True)
parser.add_argument("--family", default="mp")
parser.add_argument("--version", default="mh-1")
args = parser.parse_args()
# Must precede JAX / MACE / OpenEquivariance imports.
os.environ["OEQ_NOTORCH"] = "1"
os.environ.setdefault("MPLBACKEND", "Agg")
os.environ["JAX_PLATFORMS"] = "cuda"
import jax.numpy as jnp
import torch
from chemtrain.compose import mace_jax as mace_jax_compose
from chemtrain.deploy import exporter, graphs as export_graphs
from jax_md import space
from mace_jax.modules.wrapper_ops import EquivarianceConfig
class MaceWaterExporter(exporter.Exporter):
graph_type = export_graphs.SimpleSparseNeighborList
unit_style = "metal"
def __init__(self, model_apply, model_config, displacement):
self.model = model_apply
self.r_cutoff = model_config["r_max"]
self.nbr_order = [
model_config["num_interactions"],
2 * model_config["num_interactions"],
]
self.displacement = displacement
super().__init__()
def energy_fn(self, position, particle_data, graph, comm=None):
neighbor = graph.to_neighborlist()
# The data file uses type 1 for H and type 2 for O.
atomic_numbers = jnp.asarray((1, 8), dtype=jnp.int32)
return self.model(
position,
neighbor,
species=atomic_numbers[particle_data["species"]],
comm=comm,
)
equivariance = EquivarianceConfig(
backend="openeq",
layout="mul_ir",
group="O3_e3nn",
optimize_channelwise=True,
conv_fusion=True,
)
torch_model, model_config = mace_jax_compose.load_foundational_model(
family=args.family,
version=args.version,
)
head = "omol"
if not hasattr(torch_model, "heads") or head not in torch_model.heads:
raise ValueError(
f"Head {head!r} is unavailable. Found "
f"{tuple(getattr(torch_model, 'heads', ()))}"
)
displacement, _ = space.free()
variables, apply_fn = mace_jax_compose.mace_jax_neighborlist_from_torch(
model_config,
torch_model,
displacement,
max_edge_multiplier=None,
per_particle=True,
species_mapping=mace_jax_compose.AtomicNumberMapping(max_number=100),
scale_pot=1.0,
scale_pos=1.0,
equivariance_config=equivariance,
head=head,
)
model = MaceWaterExporter(
functools.partial(apply_fn, variables),
model_config,
displacement,
)
model.export(
communication=True,
custom_calls=exporter.OPENEQUIVARIANCE_CUSTOM_CALLS,
)
args.output.parent.mkdir(parents=True, exist_ok=True)
model.save(args.output)
args.reference_output.parent.mkdir(parents=True, exist_ok=True)
torch.save(torch_model, args.reference_output)
if __name__ == "__main__":
main()
A few things to note:
head="omol"selects the Open Molecules head while preserving the original multi-head model. The model ships with several heads trained on different datasets.scale_pot=1.0andscale_pos=1.0keep the model’s native units (eV and Å).unit_style = "metal"tells LAMMPS to expect the same.max_edge_multiplier=Nonedisables the static edge-count cap, which is required for the deployment path because the graph size is managed by the connector’s dynamic padding instead.per_particle=Truereturns per-atom energies, which is required bychemtrain.deploy.exporter.Exporter.communication=Trueinstructs the exporter to trace and bundle a second variant that performs halo exchanges between message-passing layers, enabling message passing across ranks.
Run the export (the model weights are downloaded automatically on first use):
python export_mace_mh1.py \
--output results/mace_mh1_water10k.ptb \
--reference_output results/mace_mh1_reference.model
Step 2: Prepare the LAMMPS Data File#
Convert the PDB file with ASE. specorder fixes the LAMMPS type mapping used
by the exporter: type 1 is hydrogen and type 2 is oxygen.
from ase.io import read, write
atoms = read("water_10k.pdb")
atoms.set_cell([67.1, 67.1, 67.1])
atoms.set_pbc(True)
write(
"results/water_10k.lmpdat",
atoms,
format="lammps-data",
atom_style="atomic",
specorder=["H", "O"],
masses=True,
units="metal",
)
The box length comes from the PDB remark. For another structure, set its
periodic cell explicitly and update both specorder and the atomic-number
lookup in the exporter.
Step 3: Run the Simulation#
simulation.lmp:
variable model index results/mace_mh1_water10k.ptb
variable data_file index results/water_10k.lmpdat
variable traj_dump index results/water10k.lammpstrj
variable proc_x index 2
variable proc_y index 1
variable proc_z index 1
units metal
atom_style atomic
boundary p p p
newton on
processors ${proc_x} ${proc_y} ${proc_z}
read_data ${data_file}
# Select the communication-enabled variant, which requires Newton on and also
# works with one MPI rank.
pair_style chemtrain cuda 0.85 comm on \
capacity/atom 1.2 capacity/edge 1.5
pair_coeff * * ${model}
neighbor 2.0 bin
neigh_modify every 1 delay 0 check yes
thermo 10
thermo_style custom step atoms temp pe ke etotal press
dump trajectory all custom 50 ${traj_dump} id type x y z fx fy fz
dump_modify trajectory sort id first yes
# Validate neighbor construction and force evaluation.
run 0
min_style quickmin
minimize 1.0e-4 1.0e-6 20 200
velocity all create 300.0 20260626 mom yes rot no dist gaussian
fix thermostat all nvt temp 300.0 300.0 0.1
timestep 0.0005
run 1000
Key settings:
pair_style chemtrain cuda 0.85 comm onselects the CUDA backend with 85 % memory allocation and enables halo-exchange communication.capacity/atomandcapacity/edgecontrol how buffers grow after an overflow. Capacity options belong topair_style, notpair_coeff.processors 2 1 1decomposes the box into two domains along x, one per GPU.
Running on 2 GPUs#
Expose exactly two GPUs via CUDA_VISIBLE_DEVICES and
launch LAMMPS with 2 MPI ranks:
export CUDA_VISIBLE_DEVICES=0,1
export OMP_NUM_THREADS=1
export LD_LIBRARY_PATH=/path/to/chemtrain-deploy/lib:${LD_LIBRARY_PATH:-}
mpirun -np 2 lmp -k on g 2 -sf kk \
-pk kokkos neigh half newton on comm device gpu/aware on \
-in simulation.lmp
The packaged installation finds its runtime backend and extensions
automatically. Set JCN_PJRT_PATH only when the runtime backend is installed
elsewhere. Set JCN_FFI_PATH only when extensions are installed elsewhere. It
accepts an ordered, colon-separated list of directories.
The runtime selects one visible GPU per local MPI rank. OMP_NUM_THREADS=1
avoids CPU thread oversubscription when MPI and OpenMP are both active.
Warning
Kokkos
/kk/devicecommunication strictly requires CUDA-aware MPI. Kokkos/kk/hostand host-staged Kokkos communication are unsupported.