Source code for xnn.common.train.trainer

"""Training loop: batch training, device selection, validation, checkpointing.

Keeps the moving parts explicit rather than hiding them in a framework, so the
pipeline is easy to follow and extend.

Multi-GPU / multi-node data parallelism uses native PyTorch DDP and is driven
purely by the environment: when the process was spawned by a distributed
launcher that sets ``RANK`` / ``LOCAL_RANK`` / ``WORLD_SIZE`` (``torchrun
--nproc-per-node N -m xnn train ...``, Slurm + torchrun, or an
``accelerate launch`` configured for multi-GPU), the trainer initializes the
process group, shards the data loaders with ``DistributedSampler``, wraps the
model in ``DistributedDataParallel``, all-reduces the logged metrics, and
writes checkpoints from rank 0 only. A plain ``python`` / ``xnn`` invocation
runs the unchanged single-process pipeline.

Sharded strategies (FSDP, DeepSpeed ZeRO) are deliberately not used: force and
stress losses back-propagate through gradients taken with
``create_graph=True`` (see ``ForceStressOutput``), a double backward that DDP
supports but sharded wrappers do not.
"""
from __future__ import annotations

import os

import torch
import torch.distributed as dist
from torch.nn.parallel import DistributedDataParallel
from torch.utils.data import DataLoader, DistributedSampler, random_split

from ..config import Config
from ..data import AtomicDataset, collate
from ..models import build_model, ForceStressOutput
from .losses import weighted_loss


[docs] def resolve_device(name: str) -> torch.device: """Resolve a device specification string into a ``torch.device``. Parameters ---------- name : str Device name. The special value ``"auto"`` selects ``"cuda"`` when a CUDA device is available and falls back to ``"cpu"`` otherwise. Any other value is passed through to ``torch.device`` unchanged (e.g. ``"cpu"``, ``"cuda"``, ``"cuda:1"``). Returns ------- torch.device The resolved device. """ if name == "auto": return torch.device("cuda" if torch.cuda.is_available() else "cpu") return torch.device(name)
[docs] class Trainer: """Batch training loop with validation, scheduling and checkpointing. The trainer builds the model from configuration, wraps it in :class:`ForceStressOutput` (enabling force and/or stress heads only when the corresponding loss weight is positive), sets up the data loaders, optimizer and learning-rate scheduler, then runs the full training pipeline via :meth:`fit`. When spawned by a distributed launcher (``torchrun -m xnn train ...`` on one or many nodes; see :meth:`_init_distributed`) the same pipeline runs data-parallel: the model is wrapped in ``DistributedDataParallel``, each loader is sharded with a ``DistributedSampler``, metrics are all-reduced so every rank sees global averages, and only rank 0 logs and writes checkpoints. Parameters ---------- cfg : Config Full run configuration. Fields used include ``device``, ``seed``, ``model`` (model config), ``optim`` (learning rate, weight decay, epochs, scheduler name and the energy/force/stress loss weights), ``data`` (batch size, number of workers, validation fraction) and ``output_dir`` (where checkpoints are written). train_set : AtomicDataset Training dataset of atomic structures. val_set : AtomicDataset or None, optional Explicit validation dataset. When ``None`` and ``cfg.data.val_fraction > 0``, a validation split is carved out of ``train_set`` using ``random_split`` seeded by ``cfg.seed``. When ``None`` and the fraction is zero, no validation is performed. test_set : AtomicDataset or None, optional Explicit held-out test dataset, evaluated once at the end of :meth:`fit`. When ``None`` and ``cfg.data.test_fraction > 0``, a test split is carved out of ``train_set`` (alongside the validation split, from the same seeded permutation). When ``None`` and the fraction is zero, no test evaluation is performed. Attributes ---------- cfg : Config The configuration passed in. distributed : bool Whether this process is part of a distributed launch (``WORLD_SIZE`` in the environment is greater than one). rank : int This process's global rank; 0 in a single-process run. is_main : bool Whether this is rank 0, the only rank that logs and saves. device : torch.device The resolved training device (``cuda:LOCAL_RANK`` per rank when distributed on GPUs). model : ForceStressOutput or DistributedDataParallel The model wrapped with force/stress output heads, moved to ``device`` (and wrapped in DDP when distributed); :attr:`module` always gives the bare :class:`ForceStressOutput`. train_loader : torch.utils.data.DataLoader Shuffled loader over the training set. Batch training is simply ``batch_size > 1``; set the batch size to 1 to disable it. val_loader : torch.utils.data.DataLoader or None Non-shuffled loader over the validation set, or ``None`` if there is no validation set. test_loader : torch.utils.data.DataLoader or None Non-shuffled loader over the test set, or ``None`` if there is no test set. opt : torch.optim.Adam The Adam optimizer. sched : torch.optim.lr_scheduler._LRScheduler or ReduceLROnPlateau or None The learning-rate scheduler, or ``None`` when disabled. """ def __init__(self, cfg: Config, train_set: AtomicDataset, val_set: AtomicDataset | None = None, test_set: AtomicDataset | None = None): self.cfg = cfg self.distributed = int(os.environ.get("WORLD_SIZE", "1")) > 1 self.device = self._init_distributed(resolve_device(cfg.device)) self.rank = dist.get_rank() if self.distributed else 0 self.is_main = self.rank == 0 torch.manual_seed(cfg.seed) base = build_model(cfg.model) # A wrapper such as the D4 dispersion correction can need a larger # neighbor list than the core model's cutoff that the config carries; # graphs are built lazily, so widening the datasets' radius here (before # any graph exists) keeps data and model consistent. for ds in (train_set, val_set, test_set): model_cutoff = getattr(base, "cutoff", None) if (ds is not None and model_cutoff is not None and hasattr(ds, "cutoff") and model_cutoff > ds.cutoff): ds.cutoff = float(model_cutoff) if hasattr(ds, "_cache"): ds._cache.clear() self.model = ForceStressOutput( base, compute_forces=cfg.optim.force_weight > 0, compute_stress=cfg.optim.stress_weight > 0, ).to(self.device) if self.distributed: self.model = DistributedDataParallel( self.model, device_ids=( [self.device.index] if self.device.type == "cuda" else None)) # Carve val/test splits out of the training set for whichever of the # two was not given explicitly (a single seeded permutation, so the # train/val split is unchanged by adding a test fraction of zero). f_val = cfg.data.val_fraction if val_set is None else 0.0 f_test = cfg.data.test_fraction if test_set is None else 0.0 if f_val > 0 or f_test > 0: n = len(train_set) n_val = max(1, int(n * f_val)) if f_val > 0 else 0 n_test = max(1, int(n * f_test)) if f_test > 0 else 0 splits = random_split( train_set, [n - n_val - n_test, n_val, n_test], generator=torch.Generator().manual_seed(cfg.seed)) train_set = splits[0] val_set = splits[1] if n_val else val_set test_set = splits[2] if n_test else test_set # batch training is just batch_size > 1; set to 1 to disable. # Distributed runs shard every loader across ranks; the sampler then # owns the shuffling (the two DataLoader options are exclusive). def _loader(ds, shuffle): sampler = (DistributedSampler(ds, shuffle=shuffle, seed=cfg.seed) if self.distributed else None) return DataLoader(ds, batch_size=cfg.data.batch_size, shuffle=shuffle and sampler is None, sampler=sampler, collate_fn=collate, num_workers=cfg.data.num_workers) self.train_loader = _loader(train_set, shuffle=True) self.val_loader = _loader(val_set, False) if val_set is not None else None self.test_loader = _loader(test_set, False) if test_set is not None else None self.opt = torch.optim.Adam( self.model.parameters(), lr=cfg.optim.lr, weight_decay=cfg.optim.weight_decay) self.sched = self._make_scheduler(cfg.optim.scheduler) os.makedirs(cfg.output_dir, exist_ok=True) def _init_distributed(self, dev: torch.device) -> torch.device: """Join the process group of a distributed launcher, if there is one. A launcher such as ``torchrun`` (or ``accelerate launch``) exports ``RANK`` / ``LOCAL_RANK`` / ``WORLD_SIZE`` into every process it spawns; ``self.distributed`` reflects whether that happened. In a distributed run each rank is pinned to one CUDA device selected by ``LOCAL_RANK`` (the configured device only chooses cpu vs cuda), and the process group is initialized with the matching backend -- NCCL on GPUs, Gloo on CPUs. Parameters ---------- dev : torch.device The device resolved from the configuration. Returns ------- torch.device The device this rank should train on: ``dev`` unchanged in a single-process run, ``cuda:LOCAL_RANK`` (or ``dev`` on CPU) in a distributed one. """ self._owns_pg = False if not self.distributed: return dev if dev.type == "cuda": dev = torch.device("cuda", int(os.environ.get("LOCAL_RANK", "0"))) torch.cuda.set_device(dev) if not dist.is_initialized(): dist.init_process_group("nccl" if dev.type == "cuda" else "gloo") self._owns_pg = True return dev @property def module(self) -> ForceStressOutput: """The bare model, unwrapped from ``DistributedDataParallel`` if any.""" if isinstance(self.model, DistributedDataParallel): return self.model.module return self.model def _make_scheduler(self, name: str): """Construct the learning-rate scheduler named in the config. Parameters ---------- name : str Scheduler identifier. ``"cosine"`` builds a ``CosineAnnealingLR`` over ``cfg.optim.epochs``; ``"plateau"`` builds a ``ReduceLROnPlateau`` with ``patience=10``. Any other value disables scheduling. Returns ------- torch.optim.lr_scheduler.CosineAnnealingLR or torch.optim.lr_scheduler.ReduceLROnPlateau or None The scheduler instance, or ``None`` when scheduling is disabled. """ if name == "cosine": return torch.optim.lr_scheduler.CosineAnnealingLR( self.opt, T_max=self.cfg.optim.epochs) if name == "plateau": return torch.optim.lr_scheduler.ReduceLROnPlateau(self.opt, patience=10) return None def _step(self, data, train: bool): """Run one forward/loss step on a batch, optionally back-propagating. Moves the batch to the training device, runs the model forward, computes the weighted energy/force/stress loss, and (when ``train``) performs a single optimizer step. Parameters ---------- data : AtomicGraph A (possibly batched) atomic graph produced by the collate function. train : bool When ``True``, zero the gradients, back-propagate the loss and step the optimizer. When ``False`` (validation), only the forward pass and loss computation are performed; note that gradients are still enabled because force predictions require them. Returns ------- dict of str to float The scalar loss logs for this batch, as returned by :func:`weighted_loss` (e.g. ``"loss"`` plus any of ``"energy_mse"``, ``"force_mse"``, ``"stress_mse"``). """ data = data.to(self.device) o = self.cfg.optim # Evaluation steps run the bare module: they are not followed by a # backward pass, so the DDP wrapper's gradient sync must not be armed. model = self.model if train else self.module pred = model(data) loss, logs = weighted_loss( pred, data, o.energy_weight, o.force_weight, o.stress_weight, huber_delta=o.huber_delta, huber_delta_energy=o.huber_delta_energy, huber_delta_forces=o.huber_delta_forces, huber_delta_stress=o.huber_delta_stress) if train: self.opt.zero_grad() loss.backward() self.opt.step() return logs
[docs] def fit(self): """Run the full training loop over ``cfg.optim.epochs`` epochs. For each epoch the model is trained over the whole training loader and, if a validation loader exists, evaluated over it. The best validation loss seen so far is tracked and its model saved to ``best.pt`` in the output directory. The scheduler is stepped every epoch: a ``ReduceLROnPlateau`` scheduler is stepped with the current loss, while any other scheduler is stepped without arguments. A per-epoch summary is printed. After the final epoch the model is saved to ``last.pt`` and, if a test loader exists, evaluated once on the test set (with the final-epoch weights; to test the best checkpoint instead, load ``best.pt`` into ``self.model`` and call :meth:`evaluate`). Returns ------- dict Final metrics: ``{"train": ..., "val": ..., "test": ...}``, each a dict of averaged losses (empty when that split does not exist). """ best = float("inf") tr, va = {}, {} for epoch in range(self.cfg.optim.epochs): if self.distributed: # reseed the sampler so each epoch shuffles differently self.train_loader.sampler.set_epoch(epoch) self.model.train() tr = self._avg(self._step(d, True) for d in self.train_loader) va = {} if self.val_loader is not None: self.model.eval() # forces need grad even at eval -> no torch.no_grad() va = self._avg(self._step(d, False) for d in self.val_loader) metric = va.get("loss", tr["loss"]) if metric < best: best = metric self.save(os.path.join(self.cfg.output_dir, "best.pt")) if isinstance(self.sched, torch.optim.lr_scheduler.ReduceLROnPlateau): self.sched.step(va.get("loss", tr["loss"])) elif self.sched is not None: self.sched.step() if self.is_main: self._log(epoch, tr, va) self.save(os.path.join(self.cfg.output_dir, "last.pt")) te = {} if self.test_loader is not None: te = self.evaluate() if self.is_main: print(f"test loss {te.get('loss', 0):.4e}") if self._owns_pg and dist.is_initialized(): dist.destroy_process_group() return {"train": tr, "val": va, "test": te}
[docs] def evaluate(self, loader=None): """Evaluate the current model over a data loader without training. Parameters ---------- loader : torch.utils.data.DataLoader or None, optional Loader to evaluate over. Defaults to ``self.test_loader``. Returns ------- dict of str to float Averaged losses over the loader (as in :meth:`_step`), or an empty dict when there is no loader. """ loader = self.test_loader if loader is None else loader if loader is None: return {} self.model.eval() # forces need grad even at eval -> no torch.no_grad() return self._avg(self._step(d, False) for d in loader)
def _avg(self, logs_iter): """Average a sequence of per-batch log dictionaries. In a distributed run the per-rank sums and batch counts are further summed over all ranks with an all-reduce, so every rank returns the same global averages -- the best-checkpoint decision and the plateau scheduler then stay in lockstep across ranks. Parameters ---------- logs_iter : iterable of dict of str to float An iterable yielding per-batch log dictionaries (as returned by :meth:`_step`). Keys need not be present in every dictionary. Returns ------- dict of str to float Each key mapped to the mean of its values across the batches (and across ranks when distributed). An empty iterable yields an empty dictionary (division guarded so an empty iterable does not raise). """ agg, n = {}, 0 for logs in logs_iter: n += 1 for k, v in logs.items(): agg[k] = agg.get(k, 0.0) + v if self.distributed and dist.is_initialized(): keys = sorted(agg) # NCCL reduces on the rank's GPU; Gloo reduces on CPU. t = torch.tensor( [float(n)] + [agg[k] for k in keys], dtype=torch.float64, device=self.device if self.device.type == "cuda" else "cpu") dist.all_reduce(t) n = t[0].item() agg = {k: t[i + 1].item() for i, k in enumerate(keys)} return {k: v / max(n, 1) for k, v in agg.items()} @staticmethod def _log(epoch, tr, va): """Print a one-line summary of an epoch's train and validation loss. Parameters ---------- epoch : int Zero-based epoch index. tr : dict of str to float Averaged training logs; its ``"loss"`` entry is reported. va : dict of str to float Averaged validation logs. When non-empty, its ``"loss"`` entry is appended to the message; when empty, only training loss is shown. Returns ------- None """ msg = f"epoch {epoch:4d} | train loss {tr.get('loss', 0):.4e}" if va: msg += f" | val loss {va.get('loss', 0):.4e}" print(msg)
[docs] def save(self, path: str): """Serialize the model state dict and configuration to disk. Only rank 0 writes (in a single-process run that is the only rank); the state dict is taken from the bare module, so checkpoint keys are identical with and without DDP. A barrier keeps the other ranks from racing ahead of the write. Parameters ---------- path : str Destination file path. The saved checkpoint is a dictionary with keys ``"model"`` (the model ``state_dict``) and ``"cfg"`` (the :class:`Config` used for the run). Returns ------- None """ if self.is_main: torch.save({"model": self.module.state_dict(), "cfg": self.cfg}, path) if self.distributed and dist.is_initialized(): dist.barrier()