A3 MatGL: relaxation, MD, lattice benchmark and training¶
Objectives
Relax crystals and run MD with two MatGL universal potentials.
Benchmark lattice constants and predict properties after relaxation.
Compare training from scratch with fine-tuning a pretrained potential.
MatGL [1] ships universal potentials and property models. This page runs four workflows from the MatGL tutorials on one LUMI GPU:
Potentials:
TensorNet-PES-MatPES-PBE-2025.2[2] andM3GNet-PES-MatPES-PBE-2025.2[3], both trained on MatPES PBE data [4].Property models:
M3GNet-Eform-MP-2019.4.1(formation energy) andMEGNet-BandGap-mfi-MP-2019.4.1(band gap at four levels of theory) [5, 6].No database or API key: crystals are built from space groups, and the training data are labelled with ASE’s EMT potential [7].

Each step is one command, python cli.py <step>, in examples/matgl.
Precision switch¶
MATGL_FLOAT_BITS (default 32) or --float-bits {32,64} sets the float
size for graphs, labels and models. Every model is cast to that dtype on
load, and training uses Lightning precision="64-true" in float64.
common.py
27FLOAT_BITS = int(os.environ.get("MATGL_FLOAT_BITS", "32"))
28
29
30def set_precision(bits: int = FLOAT_BITS) -> None:
31 """Set the float size for matgl graphs, labels and new models; call before loading models."""
32 import matgl
33
34 matgl.set_default_dtype("float", bits)
35
36
37def lightning_precision() -> str:
38 import torch
39
40 return "64-true" if torch.get_default_dtype() == torch.float64 else "32-true"
41
42
43@cache
44def load(name: str, device: str = "cpu"):
45 """Load a pretrained model in the default float dtype; potentials go to the GPU, property models stay on the CPU."""
46 import matgl
47 import torch
48
49 return matgl.load_model(name).to(device=device, dtype=torch.get_default_dtype())
Note
On LUMI with torch 2.7.1+rocm6.2.4, the float32 det and prod kernels
fail with CUDA driver error: 209; float64 works. The job script sets
MATGL_FLOAT_BITS=64, so all results below are float64.
Run on LUMI¶
On a login node (compute nodes have no internet), in a copy of this
repository at <SCRATCH>/mlip-matgl:
module purge && module use /appl/local/csc/modulefiles/ && module load pytorch
python -m venv --system-site-packages .venv && source .venv/bin/activate
pip install -r examples/matgl/requirements.txt
HF_HOME=$PWD/hf-home python examples/matgl/cli.py prefetch
sbatch --account=<PROJECT> scripts/lumi-matgl.sbatch
The job runs versions, relax-md, lattice, predict, train and
figures on one GCD, offline, and ends with:
== done ..., failed: none
lumi-matgl.sbatch
27export PYTHONNOUSERSITE=1
28export HF_HOME="$root/hf-home" HF_HUB_OFFLINE=1
29export MIOPEN_USER_DB_PATH="/tmp/$USER-miopen-$SLURM_JOB_ID"
30export MIOPEN_CUSTOM_CACHE_DIR="$MIOPEN_USER_DB_PATH"
31export HIP_FORCE_DEV_KERNARG=1
32# float32 det/prod kernels fail on this ROCm stack (error 209); float64 works.
33export MATGL_FLOAT_BITS=64
34export MPLCONFIGDIR="/tmp/$USER-mpl-$SLURM_JOB_ID"
35export OMP_NUM_THREADS="$SLURM_CPUS_PER_TASK"
36mkdir -p "$MIOPEN_USER_DB_PATH" "$MPLCONFIGDIR"
37trap 'rm -rf "$MIOPEN_USER_DB_PATH" "$MPLCONFIGDIR"' EXIT
38
39cd "$root/examples/matgl"
40failed=()
41for step in versions relax-md lattice predict train figures; do
42 echo "== $step $(date +%T)"
43 srun --ntasks=1 --gpus=1 --cpu-bind=cores python cli.py "$step" || failed+=("$step")
44done
45echo "== done $(date +%T), failed: ${failed[*]:-none}"
Conditions for all results: one AMD MI250X GCD, float64, one run (about 8.5 min in total), matgl 4.0.3, torch
2.7.1+rocm6.2.4, lightning 2.6.1, torch_geometric 2.8.0.post1, ase
3.29.0, pymatgen 2026.9.24.
Relaxation and MD¶
CsCl is relaxed from a = 4.5 Å (FIRE, fmax 0.01 eV/Å), then a 3×3×3 supercell (54 atoms) runs 1000 steps of 1 fs NVT Langevin MD at 300 K.
relax_md.py
24def run_md(potential, structure, steps: int, temperature: int, seed: int = 0) -> dict:
25 atoms = AseAtomsAdaptor().get_atoms(structure * (3, 3, 3))
26 MaxwellBoltzmannDistribution(atoms, temperature_K=temperature, rng=_rng(seed))
27 driver = MolecularDynamics(atoms, potential=potential, ensemble="nvt_langevin",
28 temperature=temperature, timestep=1.0, friction=0.01)
29 log = {"energy_per_atom": [], "temperature": []}
30
31 def record():
32 log["energy_per_atom"].append(round(atoms.get_potential_energy() / len(atoms), 5))
33 log["temperature"].append(round(atoms.get_temperature(), 2))
34
35 driver.dyn.attach(record, interval=10)
36 driver.run(steps)
37 return {"n_atoms": len(atoms), "steps": steps, "interval": 10, **log}
TensorNet: a = 4.2096 A, E = -6.5076 eV, {'relax_s': 2.02, 'md_s': 20.33}
M3GNet: a = 4.2535 A, E = -6.5325 eV, {'relax_s': 0.78, 'md_s': 26.93}
TensorNet |
M3GNet |
|
|---|---|---|
relaxed a (expt 4.123 Å) |
4.2096 Å |
4.2535 Å |
relaxed energy (2-atom cell) |
−6.5076 eV |
−6.5325 eV |
relaxation |
21 steps, 2.02 s |
20 steps, 0.78 s |
MD, 1000 steps |
20.33 s |
26.93 s |
mean temperature, second half |
297.3 K |
306.2 K |

Both overestimate a (+2.1 % and +3.2 %), as PBE often does for ionic crystals.
The thermostat holds 300 K within a few per cent over 0.5 ps.
TensorNet ran first, so its relaxation time likely includes GPU warm-up.
Lattice benchmark¶
23 cubic crystals (elements, III-V and II-VI semiconductors, rock salts, CsCl, SrTiO3), all started from a = 5.0 Å and compared with room-temperature experiment.
lattice.py
18def relaxed_a(relaxer: Relaxer, row) -> float:
19 structure = build(row.prototype, row.species.split(), A_START)
20 final = relaxer.relax(structure, fmax=0.01, steps=1000)["final_structure"]
21 return final.volume ** (1 / 3) # conventional cubic cell
TensorNet |
M3GNet |
|
|---|---|---|
MAE |
0.0525 Å |
0.0532 Å |
MAPE |
1.11 % |
1.11 % |
mean signed error |
+0.89 % |
+0.92 % |
max error |
2.46 % (Au) |
3.20 % (CsCl) |
time, 23 relaxations |
28.9 s |
106.49 s |

Same accuracy for both; TensorNet is 3.7 times faster here.
Both mostly overestimate a, typical of PBE. Underestimates: Al, Fe and SrTiO3 (both), TiN (M3GNet).
Per-crystal values:
examples/matgl/results/lattice.csv.
Relax, then predict¶
TensorNet relaxes five crystals from a = 4.5 Å; the property models are applied before and after relaxation. The band-gap model takes the level of theory as an input: PBE, GLLB-SC, HSE or SCAN.
predict.py
24def properties(structure) -> dict:
25 eform = float(load(EFORM_MODEL).predict_structure(structure))
26 gap = load(GAP_MODEL)
27 gaps = {k: float(gap.predict_structure(structure, state_attr=torch.tensor([v])))
28 for k, v in FIDELITIES.items()}
29 return {"eform_eV_atom": round(eform, 3), **{f"gap_{k}_eV": round(g, 2) for k, g in gaps.items()}}
Crystal |
a (Å) |
Eform, eV/atom: unrelaxed → relaxed (expt) |
gap after relaxation, eV: PBE / GLLB-SC / HSE / SCAN (expt) |
|---|---|---|---|
SrTiO3 |
3.9427 |
−1.932 → −3.561 (−3.47) |
1.83 / 3.74 / 3.52 / 2.29 (3.25) |
MgO |
4.2474 |
−2.664 → −3.072 (−3.12) |
4.76 / 8.23 / 6.35 / 5.57 (7.8) |
NaCl |
5.7227 |
−1.111 → −2.095 (−2.13) |
5.13 / 9.73 / 6.29 / 5.75 (8.5) |
GaAs |
5.7502 |
−0.896 → −0.338 (−0.37) |
0.22 / 0.45 / 0.57 / 0.63 (1.42) |
Si |
5.45 |
−0.223 → −0.006 (0.0) |
0.50 / 1.24 / 1.11 / 0.73 (1.12) |
Total time for five crystals: 8.41 s.

Relax first: after relaxation every formation energy is within 0.1 eV/atom of experiment; before, errors reach 1.5 eV/atom.
PBE gaps are too small, as expected; GLLB-SC and HSE are closer.
GaAs is underestimated at every level.
Training from scratch and fine-tuning¶
100 strained and rattled 32-atom fcc Cu cells labelled with EMT, split 70/15/15, 50 epochs, same test set for both runs:
Scratch: small M3GNet (two blocks, 32 units), learning rate 2 × 10⁻³.
Fine-tuned: MatPES M3GNet, learning rate 1 × 10⁻⁴. EMT energies are on a different scale from PBE, so the Cu reference energy is shifted by the mean difference first; the pretrained scaling is kept.
train.py
27def finetune_module(structures, energies) -> PotentialLightningModule:
28 """Pretrained M3GNet with the Cu reference shifted onto the EMT energy scale."""
29 from matgl.ext.ase import PESCalculator
30 from pymatgen.io.ase import AseAtomsAdaptor
31
32 pot = load(PES_MODELS["M3GNet"], device())
33 calc = PESCalculator(pot)
34 shift = []
35 for s, e in zip(structures, energies):
36 atoms = AseAtomsAdaptor().get_atoms(s)
37 atoms.calc = calc
38 shift.append((e - atoms.get_potential_energy()) / len(atoms))
39 refs = pot.element_refs.property_offset.detach().cpu().numpy().copy()
40 refs[pot.model.element_types.index("Cu")] += float(np.mean(shift))
41 return PotentialLightningModule(model=pot.model, element_refs=refs, lr=1e-4,
42 data_mean=pot.data_mean, data_std=pot.data_std)
scratch {'energy_meV_atom': 59.13, 'force_eV_A': 0.5655} -> {'energy_meV_atom': 5.87, 'force_eV_A': 0.0147} 48.89 s
finetune {'energy_meV_atom': 15.88, 'force_eV_A': 0.0609} -> {'energy_meV_atom': 1.21, 'force_eV_A': 0.0038} 66.89 s
Run |
test energy MAE, before → after (meV/atom) |
test force MAE, before → after (eV/Å) |
fit time |
final validation force MAE |
|---|---|---|---|---|
M3GNet from scratch |
59.13 → 5.87 |
0.5655 → 0.0147 |
48.89 s |
0.01625 eV/Å |
fine-tuned MatPES M3GNet |
15.88 → 1.21 |
0.0609 → 0.0038 |
66.89 s |
0.00400 eV/Å |

With 70 training cells, fine-tuning ends about five times more accurate than training from scratch for energies and four times for forces.
The pretrained model starts with forces nine times closer to EMT, after only a shift of the reference energy.
One seed and one split: treat this as one sample, not a benchmark.
Keypoints
MatGL potentials relax and run MD through ASE; set float64 on LUMI.
Relax before predicting properties; compare against the right reference.
Fine-tuning a pretrained potential beats training from scratch on small data.
References¶
T. W. Ko et al., Materials Graph Library (MatGL). arXiv:2503.03837
G. Simeon and G. De Fabritiis, TensorNet. arXiv:2306.06482
C. Chen and S. P. Ong, Nat. Comput. Sci. 2, 718 (2022). doi:10.1038/s43588-022-00349-3
A. D. Kaplan et al., MatPES. arXiv:2503.04070
C. Chen et al., Chem. Mater. 31, 3564 (2019). doi:10.1021/acs.chemmater.9b01294
C. Chen et al., Nat. Comput. Sci. 1, 46 (2021). doi:10.1038/s43588-020-00002-x
K. W. Jacobsen, P. Stoltze and J. K. Nørskov, Surf. Sci. 366, 394 (1996). doi:10.1016/0039-6028(96)00816-3
The workflows follow the
MatGL tutorials
(BSD-3-Clause); see THIRD_PARTY.md.