A1 Batched relaxation for screening

Objectives

  • Explain why batched GPU relaxation speeds up screening.

  • Relax one set serially with ASE and in one TorchSim batch.

  • Compare wall time and energy agreement.

Serial relaxation leaves the GPU idle; batching fills it (GPU engines).

  • One MACE-MP model, two routes: serial ASE and one batched TorchSim call on one GPU.

  • Part A: own pixi environment, MACE-MP-0b small (not Part B’s MACE-MP-0a).

  • Workload: rattled copies of Cu fcc, Si diamond, Fe bcc and Al fcc (8 to 32 atoms); each copy needs its own relaxation.

workload.py
 7FAMILIES = {
 8    "Cu-fcc": lambda: bulk("Cu", "fcc", a=3.61, cubic=True).repeat((2, 2, 2)),
 9    "Si-diamond": lambda: bulk("Si", "diamond", a=5.43, cubic=True),
10    "Fe-bcc": lambda: bulk("Fe", "bcc", a=2.87, cubic=True).repeat((2, 2, 2)),
11    "Al-fcc": lambda: bulk("Al", "fcc", a=4.05, cubic=True).repeat((2, 2, 2)),
12}
13
14
15def build_workload(n_variants: int, seed: int = 42) -> tuple[list[Atoms], list[str]]:
16    rng = np.random.default_rng(seed)
17    structures, labels = [], []
18    for name, make in FAMILIES.items():
19        for i in range(n_variants):
20            atoms = make()
21            atoms.rattle(stdev=rng.uniform(0.03, 0.12), seed=int(rng.integers(1e6)))
22            structures.append(atoms)
23            labels.append(f"{name}-{i:02d}")
24    return structures, labels

Serial ASE relaxation versus one batched TorchSim call.

Serial and batched runs

Both routes use FIRE with a fixed cell.

  • Stop at --fmax (default 0.05 eV/Å) or --max-steps.

  • Serial relaxes only the first --baseline-n structures and extrapolates to the full set; a full serial run would use most of the allocation.

runners.py
17class SerialRelaxer:
18    def __init__(self, make_calc, fmax: float, max_steps: int):
19        self.make_calc = make_calc
20        self.fmax = fmax
21        self.max_steps = max_steps
22
23    def run(self, structures: list[Atoms]) -> RelaxResult:
24        calc = self.make_calc()
25        energies, relaxed = [], []
26        start = time.perf_counter()
27        for original in structures:
28            atoms = original.copy()
29            atoms.calc = calc
30            FIRE(atoms, logfile=None).run(fmax=self.fmax, steps=self.max_steps)
31            energies.append(float(atoms.get_potential_energy()))
32            relaxed.append(atoms)
33        return RelaxResult(time.perf_counter() - start, energies, relaxed)
34
35
36class BatchedRelaxer:
37    def __init__(self, model, fmax: float, max_steps: int, autobatch: bool = False):
38        self.model = model
39        self.fmax = fmax
40        self.max_steps = max_steps
41        self.autobatch = autobatch
42
43    def run(self, structures: list[Atoms]) -> RelaxResult:
44        import torch_sim as ts
45
46        start = time.perf_counter()
47        state = ts.optimize(
48            system=structures,
49            model=self.model,
50            optimizer=ts.Optimizer.fire,
51            convergence_fn=ts.generate_force_convergence_fn(force_tol=self.fmax),
52            max_steps=self.max_steps,
53            autobatcher=self.autobatch,
54            pbar=True,
55        )
56        wall = time.perf_counter() - start
57        energies = [float(e) for e in state.energy]
58        return RelaxResult(wall, energies, ts.io.state_to_atoms(state))
  • TorchSim advances all ASE Atoms as one batched state; converged structures leave the batch.

  • --autobatch splits the set to fit GPU memory.

  • Same checkpoint for both; TorchSim needs the raw model (return_raw_model=True):

models.py
 52def load_mace(name: str, device: torch.device, dtype: torch.dtype,
 53              checkpoint: str | None = None) -> ModelPair:
 54    from mace.calculators.foundations_models import mace_mp
 55    from torch_sim.models.mace import MaceModel, MaceUrls
 56
 57    source = checkpoint or getattr(MaceUrls, MACE_MODELS[name]).value
 58    options = {"model": source, "device": str(device),
 59               "default_dtype": str(dtype).removeprefix("torch.")}
 60
 61    def make_torchsim():
 62        raw = mace_mp(return_raw_model=True, **options)
 63        return MaceModel(model=raw, device=device, dtype=dtype,
 64                         compute_forces=True, compute_stress=False)
 65    return ModelPair(keeps_dtype(make_torchsim),
 66                     keeps_dtype(lambda: mace_mp(**options)), checkpoint)
 67
 68
 69def orb_ase_calc(orbff, adapter, device: torch.device, dtype: torch.dtype):
 70    """ORBCalculator builds its input graph in the global default dtype."""
 71    from orb_models.forcefield.inference.calculator import ORBCalculator
 72
 73    class Calculator(ORBCalculator):
 74        def calculate(self, *args, **kwargs):
 75            with default_dtype(dtype):
 76                super().calculate(*args, **kwargs)
 77    return Calculator(orbff, adapter, device=device)
 78
 79
 80def load_orb(name: str, device: torch.device, dtype: torch.dtype,
 81             checkpoint: str | None = None, d3: bool = False) -> ModelPair:
 82    from orb_models.forcefield import pretrained
 83    from orb_models.forcefield.inference.d3_model import AlchemiDFTD3, D3SumModel
 84    from torch_sim.models.orb import OrbModel
 85
 86    loader = getattr(pretrained, ORB_MODELS[name])
 87    options = {"device": device, "precision": ORB_PRECISION[dtype], "compile": False}
 88    if checkpoint:
 89        options["weights_path"] = checkpoint
 90    with default_dtype():
 91        orbff, adapter = loader(**options)
 92    if d3:
 93        orbff = D3SumModel(orbff, AlchemiDFTD3(functional="PBE", damping="BJ"))
 94    return ModelPair(lambda: OrbModel(orbff, adapter, device=device, dtype=dtype),
 95                     lambda: orb_ase_calc(orbff, adapter, device, dtype), checkpoint)
 96
 97
 98def load_matgl(name: str, device: torch.device, dtype: torch.dtype) -> ModelPair:
 99    """ASE only: TorchSim has no MatGL model, and MatGL runs in float32."""
100    import matgl
101    from matgl.ext.ase import PESCalculator
102
103    if dtype != torch.float32:
104        raise ValueError("MatGL models run in float32; pass --dtype float32")
105    with default_dtype():
106        potential = matgl.load_model(MATGL_MODELS[name]).to(device)
107    return ModelPair(None, lambda: PESCalculator(potential, stress_unit="eV/A3"))
108
109

Outputs: ASE versus TorchSim energies on the shared structures, summary.json and relaxed.extxyz. A local --checkpoint file also gets its SHA-256 recorded.

Laptop run

  • Package examples/torchsim/ with its own pixi environment; entry point examples/torchsim/__main__.py.

  • smoke uses a toy Lennard-Jones potential: no download, seconds to run, no physical meaning.

cd examples/torchsim
pixi run smoke

Output goes to examples/torchsim_smoke/. Existing output directories are never overwritten: remove it or pass a new --outdir.

Expected output (numbers vary):

model=lj device=cpu dtype=float64 structures=8 serial subset=2
                             wall time   s/structure
serial, measured                 0.1 s        0.03 s   (2 structures)
serial, estimated                0.2 s        0.03 s   (8 structures)
batched, measured                5.4 s        0.68 s   (8 structures)
estimated serial / batched: 0.04
max |E_ASE - E_TorchSim| on the serial subset: 2.03e-01 eV
  • Batching is slower on a CPU with a cheap potential: no GPU parallelism.

  • The energy gap comes from different Lennard-Jones cutoff and shift conventions in ASE and TorchSim. Only the MACE run is a consistency check (a few meV).

MACE on a CPU: the cpu task relaxes four structures for at most 20 steps into examples/torchsim_cpu/. First run downloads MACE-MP-0b small (about 68 MB):

pixi run cpu

Leonardo run (one A100)

Compute nodes have no internet. On a login node, install the pixi environment in your lesson copy and download the checkpoint to a private location outside Git:

Commands
cd /leonardo_scratch/fast/<PROJECT>/<USER>/mlip-hands-on/examples/torchsim
pixi install
curl -L -o <SCRATCH>/models/mace-mp-0b-small.model \
  https://github.com/ACEsuit/mace-mp/releases/download/mace_mp_0b/mace_agnesi_small.model
printf '%s  %s\n' \
  7e3a0abcaf41e03a80e69f778e1b11b29de1cca704783dc25917a736392f8cf0 \
  <SCRATCH>/models/mace-mp-0b-small.model | sha256sum -c -

The job checks this SHA-256 and refuses any other file. Submit from the repository root; missing inputs or an existing output directory abort it:

Commands
export MLIP_LESSON_ROOT=/leonardo_scratch/fast/<PROJECT>/<USER>/mlip-hands-on
export MLIP_TORCHSIM_CHECKPOINT=<SCRATCH>/models/mace-mp-0b-small.model
export MLIP_RESULTS_DIR=/path/to/private/results
sbatch --account=<PROJECT> \
  --export=ALL,MLIP_LESSON_ROOT,MLIP_TORCHSIM_CHECKPOINT,MLIP_RESULTS_DIR \
  scripts/test-leonardo-torchsim.sbatch
test-leonardo-torchsim.sbatch
23outdir="$MLIP_RESULTS_DIR/leonardo-torchsim-${SLURM_JOB_ID}"
24test ! -e "$outdir"
25cd "$MLIP_LESSON_ROOT/examples"
26srun --nodes=1 --ntasks=1 --gpus=1 --cpu-bind=cores \
27  pixi run --frozen --manifest-path "$manifest" \
28  python -m torchsim --model mace-small --checkpoint "$MLIP_TORCHSIM_CHECKPOINT" \
29  --device cuda --dtype float64 --n-variants 16 --baseline-n 8 --outdir "$outdir"
30jq -e '.n_structures == 64 and .baseline_n == 8 and .batched_wall_s > 0' \
31  "$outdir/summary.json" >/dev/null
32printf 'Leonardo TorchSim batched relaxation passed: %s\n' "$outdir"

This script has not yet been qualified. Neither result row below comes from it: float64 is from an earlier script with the same workload and options; float32 is from a separate tuned run (options under the table).

Leonardo results

One A100, MACE-MP-0b small, no cuEquivariance. Serial measured 8 structures, scaled to the full set.

Precision and batching

Structures

Serial, 8 measured (s)

Serial, estimated (s)

Batched (s)

Estimated serial / batched

float64

64

37.4

299.5

59.3

5.05

float32, --autobatch

128

38.7

619.6

85.2

7.27

The float32 run used --dtype float32 --n-variants 32 --autobatch.

Warning

Single examples, not a benchmark.

  • One run per row: no repeats, medians or ranges.

  • Full-set serial time is extrapolated from 8 structures.

  • MACE-MP-0b small, not Part B’s MACE-MP-0a: energies and costs are not interchangeable.

  • No cuEquivariance kernels; they would change the timings.

  • Relaxation, not MD: do not compare with Part B timings.

LUMI (AMD MI250X) check

MACE runs on AMD via ROCm PyTorch (CSC pytorch module). Separate ASE script, no TorchSim batching, one MI250X GCD:

Step

System

Time (s)

Single point

32 atoms

1.6

FIRE relaxation, serial

6 structures (Cu, Al, Fe, Si)

17.0

MD, 600 K

200 steps, 32 atoms

3.9

  • One run, float64, MACE-MP-0a small.

  • Shows the model runs on AMD; not a speed comparison with the A100 table.

  • CUDA-only kernels (cuEquivariance, ALCHEMI) are not available on AMD.

Reading the results

  • One A100: batched about 5 times faster than serial estimate (float64), about 7 times (float32, --autobatch).

  • CPU with the toy potential: batching slower; the gain is GPU parallelism.

  • One run each, extrapolated serial time: ratios are indicative.

Keypoints

  • Batching runs many relaxations in one GPU call; 5 to 7 times faster on an A100.

  • On a CPU, batching gives no gain.

  • Check serial and batched energies agree before comparing timings.