"""The training loop every model's trainer is built on."""
import math
import subprocess
from abc import abstractmethod
from copy import deepcopy
from itertools import chain, islice
import torch
import torch.distributed as dist
from omegaconf import DictConfig, OmegaConf
from torch.nn.parallel import DistributedDataParallel as DDP
from torch.utils.data import DataLoader
from tqdm import tqdm
from core.optim import DEFAULT_NO_WEIGHT_DECAY, param_groups
from core.utils.checkpoint import save_ckpt
from core.utils.distributed import cleanup_ddp, setup_ddp
from core.utils.logger import EPOCH_PBAR_FMT, Logger
from core.utils.util import (
Precision,
expand_path,
get_device,
rank_zero_only,
seed_everything,
)
__api_ref__ = {
"description": None,
"sections": [{"title": None, "autosummary": ["BaseTrainer"]}],
}
class _OneBatchLoader:
"""A loader capped at its first batch, for ``cfg.debug`` runs.
Iteration and ``len`` see the cap, everything else passes through to the loader.
"""
def __init__(self, loader: DataLoader):
self.loader = loader
def __iter__(self):
return islice(iter(self.loader), 1)
def __len__(self):
return min(len(self.loader), 1) # an empty split stays empty
def __getattr__(self, name):
return getattr(self.loader, name)
[docs]
class BaseTrainer:
"""The epoch loop, and everything around it that depends on neither task nor model.
Logging, checkpointing, DDP, early stopping and best-model selection live here;
a subclass supplies :meth:`setup` and the epoch internals.
"""
def __init__(self, cfg: DictConfig, rank: int, world_size: int):
"""Initialize the base trainer.
Sets up distributed training, logging, and loads a checkpoint if specified.
Calls :meth:`setup` to initialize the model, data loaders, and optimizers.
Args:
cfg: Hydra configuration object.
rank: Local process rank (device index).
world_size: Total number of processes in the distributed group.
"""
assert cfg.get("data_root"), (
"data_root is unset: set BWB_DATA_ROOT, its per-build override, or pass data_root="
)
seed_everything(cfg.seed)
setup_ddp(rank, world_size, cfg.ddp)
self.cfg = cfg
self.precision = Precision(cfg.precision)
self.rank = rank
self.world_size = world_size
self.is_distributed = dist.is_initialized()
self.device = get_device(rank)
self.train_step = 0
self.epoch = 0
self._grad_norms = []
self.to_ckpt = {}
if self.cfg.debug:
self.cfg.wandb.mode = "disabled"
self.cfg.ckpt.enable = False
self.cfg.num_workers = 0
self.cfg.num_epochs = min(self.cfg.num_epochs, 1) # 0 stays eval-only
if "grad_accum_steps" in self.cfg:
self.cfg.grad_accum_steps = 1 # else no batch ever reaches an optimizer step
if OmegaConf.select(self.cfg, "save_preds.enable"):
self.cfg.save_preds.enable = False # a truncated file scores as a whole one
self._setup_logger()
self.logger.save_config(self.cfg)
self.best_model = None
self.best_metrics = {}
self._best_tracking_initialized = False
ckpt = self._load_ckpt()
self.setup(ckpt)
self._restore_checkpoint_items(ckpt)
if self.cfg.debug:
self._truncate_loaders()
self._setup_ckpt()
def __del__(self):
cleanup_ddp()
[docs]
def train(self) -> dict | None:
"""Run the full training loop.
Iterates over epochs, calling :meth:`train_epoch` each epoch and
:meth:`val_epoch` every ``val.every_n_epochs`` epochs and at the final epoch.
Checkpoints are saved after each epoch. Supports early stopping via the
return value of :meth:`val_epoch`.
Returns:
Final metrics dict, or ``None`` if no metrics were logged.
"""
if self.cfg.val.first:
self.val_epoch()
epoch_pbar = tqdm(
range(self.epoch, self.cfg.num_epochs),
initial=self.epoch,
total=self.cfg.num_epochs,
bar_format=EPOCH_PBAR_FMT,
dynamic_ncols=True,
disable=(self.rank != 0) or self.cfg.get("disable_pbar", False),
)
self.logger.set_epoch_pbar(epoch_pbar)
early_stop = False
for epoch in epoch_pbar:
self.epoch = epoch
self.barrier()
self.train_epoch()
self.log_epoch_grad_norm()
self._save_checkpoint()
self._save_last_checkpoint()
should_do_val = (self.epoch % self.cfg.val.every_n_epochs == 0) or ( # if user asked
self.epoch == self.cfg.num_epochs - 1 # if last epoch
)
if should_do_val:
early_stop = self.val_epoch()
if early_stop:
break
if self.cfg.test.get("enable", True):
self.test()
self.log_training_results()
return self.__dict__.get("return_value", None)
[docs]
@abstractmethod
def train_epoch(self):
"""Perform a training epoch."""
...
[docs]
@abstractmethod
def val_epoch(self):
"""Perform a validation epoch.
Called every `val.every_n_epochs` epochs, and at the end of training.
Returns:
early_stop : bool, True if early stopping should be triggered, False (or falsey) otherwise.
"""
...
[docs]
def test(self):
"""Perform a test epoch.
Called at the end of training if `test.enable` is True.
"""
self.logger.warn("Test method not implemented for this Trainer, doing nothing.")
[docs]
def log_training_results(self):
"""Log the training results, at the end of training."""
...
[docs]
@abstractmethod
def setup(self, ckpt: dict | None):
"""Set up the trainer.
This method is called after setting up distributed, loading the checkpoint, and setting up logging.
Args:
ckpt: The checkpoint dictionary, if any.
"""
...
def _setup_logger(self):
self.logger = Logger(self.rank, disable_pbar=self.cfg.get("disable_pbar", False))
self.logger.init_wandb(self.cfg.wandb, log_code=self.cfg.get("log_code", False))
self.logger.info(f"Run ID: {self.logger.run_id} (seed {self.cfg.seed})")
if self.cfg.wandb.mode != "online":
self.logger.warn(f"W&B mode: {self.cfg.wandb.mode}")
def _truncate_loaders(self):
"""Cap every split at one batch.
Runs after :meth:`setup`, so whatever was sized from ``len(train_loader)`` there,
a scheduler above all, is unaffected.
"""
for name in ("train_loader", "val_loader", "test_loader", "eval_loader"):
loader = getattr(self, name, None)
if loader is not None:
setattr(self, name, _OneBatchLoader(loader))
self.logger.warn(
f"Debug mode: one batch per split, {self.cfg.num_epochs} epoch(s), "
"no W&B or checkpoints"
)
[docs]
def add_checkpoint_items(self, **kwargs):
"""Add items to the checkpoint dictionary, saved and restored by state_dict.
Registering is what makes an object survive a resume, so every rank does it;
writing the file stays rank zero's job.
Args:
kwargs: The items to add to the checkpoint dictionary.
"""
self.to_ckpt.update(kwargs)
def _restore_checkpoint_items(self, ckpt: dict | None):
"""Load the state of everything :meth:`add_checkpoint_items` registered.
Runs once :meth:`setup` has returned and the registry is complete, so
registering an optimizer is all a trainer needs for a resume that continues
rather than restarts. Only under ``ckpt.resume``, since a finetune wants the
weights and a fresh optimizer. The model is excluded: loading it belongs to the
trainer, which may have to extend a vocabulary or graft a task head.
"""
if ckpt is None or not self.cfg.ckpt.resume or self.cfg.ckpt.model_only:
return
restored, absent = [], []
for name, item in self.to_ckpt.items():
if name == "model" or not hasattr(item, "load_state_dict"):
continue
key = f"{name}_state_dict"
if key not in ckpt:
absent.append(key)
continue
target = item.module if isinstance(item, DDP) else item
try:
target.load_state_dict(ckpt[key])
except (ValueError, RuntimeError) as e:
raise RuntimeError(
f"Cannot restore '{name}' from {self.cfg.ckpt.load_from}: {e}. State "
"that no longer fits the run means this is a finetune rather than a "
"resume: pass ckpt.model_only=true or ckpt.resume=false."
) from None
restored.append(name)
if restored:
self.logger.info(f"Resumed state: {', '.join(restored)}")
if absent:
self.logger.warn(f"Checkpoint carries no {', '.join(absent)}; left at init")
@rank_zero_only
def _setup_ckpt(self):
if not self.cfg.ckpt.enable:
self.logger.warn("Checkpointing disabled")
self.ckpt_dir = None
return
# Create checkpoint directory, e.g. <ckpt.dir>/<trainer>_<run_dir>/best.pt,
# loaded back with ckpt.load_from=<trainer>_<run_dir>/best.pt.
trainer = (self.cfg.get("hydra_choices") or {}).get("trainer")
run_dir = f"{trainer}_{self.logger.run_id}" if trainer else self.logger.run_id
self.ckpt_dir = (expand_path(self.cfg.ckpt.dir) / run_dir).resolve()
self.ckpt_dir.mkdir(exist_ok=True, parents=True)
self.logger.info(f"Checkpoint directory: {self.ckpt_dir}")
# Save git diff patch and commit ID (best effort: absent outside a git checkout)
try:
diff_path = self.ckpt_dir / "diff.patch"
diff_path.write_bytes(subprocess.check_output(["git", "diff", "HEAD"]))
commit_id = subprocess.check_output(["git", "rev-parse", "HEAD"]).decode("utf-8")
(self.ckpt_dir / "commit_id.txt").write_text(commit_id.strip())
self.logger.wandb_run.log_artifact(diff_path)
self.logger.info("Saved diff.patch and commit_id.txt")
except Exception as e:
self.logger.warn(f"Could not record git state: {e}")
# Tell user what will be checkpointed
if len(self.to_ckpt) > 0:
self.logger.info(f"Checkpoints will track: {list(self.to_ckpt.keys())}")
else:
self.logger.warn("No items specified for checkpointing")
@rank_zero_only
def _save_checkpoint(self):
if self.ckpt_dir is None:
return
every_n_epochs = self.cfg.ckpt.every_n_epochs
if (every_n_epochs is None) or (self.epoch % every_n_epochs != 0):
return
self.last_ckpt_path = save_ckpt(
filepath=self.ckpt_dir / f"epoch_{self.epoch}.pt",
train_step=self.train_step,
epoch=self.epoch,
run_id=self.logger.run_id,
cfg=self.cfg,
**self.to_ckpt,
)
@rank_zero_only
def _save_last_checkpoint(self):
if self.ckpt_dir is None:
return
if not self.cfg.ckpt.get("save_last", True):
return
self.last_ckpt_path = save_ckpt(
filepath=self.ckpt_dir / "last.pt",
train_step=self.train_step,
epoch=self.epoch,
run_id=self.logger.run_id,
cfg=self.cfg,
**self.to_ckpt,
)
@rank_zero_only
def _save_best_checkpoint(self):
if self.ckpt_dir is None:
return
self.best_ckpt_path = save_ckpt(
filepath=self.ckpt_dir / "best.pt",
train_step=self.train_step,
epoch=self.epoch,
run_id=self.logger.run_id,
cfg=self.cfg,
**self.to_ckpt,
)
def _load_ckpt(self) -> dict | None:
if self.cfg.ckpt.load_from is None:
return
ckpt_path = expand_path(self.cfg.ckpt.load_from)
if not ckpt_path.exists():
# try this other path
ckpt_path = expand_path(self.cfg.ckpt.dir) / self.cfg.ckpt.load_from
if not ckpt_path.exists():
raise ValueError(
f"Checkpoint not found at {self.cfg.ckpt.load_from} or {ckpt_path}"
)
self.cfg.ckpt.load_from = ckpt_path
self.logger.info(f"Loading checkpoint {ckpt_path}")
ckpt = torch.load(
ckpt_path,
map_location=self.device,
weights_only=False,
)
if self.cfg.ckpt.resume:
self.epoch = ckpt["epoch"] + 1
self.train_step = ckpt["train_step"]
self.logger.info(f"Resuming from: epoch = {self.epoch}, train_step = {self.train_step}")
return ckpt
[docs]
def make_ddp(self, module: torch.nn.Module, find_unused_parameters: bool = False):
"""Wrap a module in DistributedDataParallel if running in distributed mode.
Args:
module: The module to wrap.
find_unused_parameters: Passed to DDP. Set to ``True`` if some parameters
are not used in the forward pass. Defaults to ``False``.
Returns:
torch.nn.Module: The DDP-wrapped module, or the original module if not distributed.
"""
if self.is_distributed:
ret = torch.nn.SyncBatchNorm.convert_sync_batchnorm(module.to(self.rank))
ret = DDP(
ret,
device_ids=[self.rank],
find_unused_parameters=find_unused_parameters,
)
return ret
else:
return module
[docs]
def barrier(self):
"""Synchronize all processes in the distributed group."""
if self.is_distributed:
dist.barrier()
[docs]
def reduce_mean(self, total: float, count: float) -> float:
"""Mean over the whole split rather than over this rank's shard of it.
A distributed sampler hands every rank a different slice, so a locally divided
sum leaves the ranks disagreeing on the score they select on. Every rank must
call this the same number of times, in the same order.
Args:
total: This rank's summed quantity.
count: This rank's number of terms in that sum.
Returns:
The pooled mean, NaN if no rank contributed a term.
"""
if self.is_distributed:
pair = torch.tensor([total, count], dtype=torch.float64, device=self.device)
dist.all_reduce(pair)
total, count = pair.tolist()
return float("nan") if count == 0 else total / count
[docs]
def reset_best_tracking(self, minimize: bool | None = None):
"""Initialize the best-score bookkeeping used by :meth:`save_if_best`.
:meth:`save_if_best` calls this itself on first use, so a trainer only needs it
to pin a direction that config does not carry: pass ``minimize`` when the
trainer fixes it in code, or call it from ``setup`` after deciding
``val.minimize`` there. Direction otherwise comes from ``val.minimize``,
defaulting to maximizing.
A trainer that never tracks a best never gets these attributes, so it stays
free to use those names itself. Only ``best_model`` and ``best_metrics`` are
always defined, since code outside this method reads them.
"""
if minimize is None:
minimize = bool(OmegaConf.select(self.cfg, "val.minimize", default=False))
self.val_minimize = minimize
patience = OmegaConf.select(self.cfg, "val.patience")
if patience is None:
# unset patience means never stop early, and num_epochs is that bound
patience = OmegaConf.select(self.cfg, "num_epochs", default=float("inf"))
self.patience = self._initial_patience = patience
self.start_patience = OmegaConf.select(self.cfg, "val.start_patience", default=0)
self.best_val = float("inf") if self.val_minimize else float("-inf")
self.best_epoch = 0
self.best_model = None
self.best_metrics = {}
self._best_tracking_initialized = True
[docs]
def save_if_best(self, score: float, metrics: dict | None = None) -> bool:
"""Keep this epoch's weights if its score is the best seen so far.
On an improvement: records the score and epoch, snapshots the weights into
``best_model``, and writes ``best.pt``. The two have different readers:
``best_model`` is what ``test`` reloads in this same process, and it survives
``ckpt.enable=false``; ``best.pt`` is what a later run loads. Pass ``metrics``
to also record the ``best/val/*`` dict for logging. Feed the result to
:meth:`step_patience` to early-stop as well; a trainer that only wants
best-model selection can call this alone.
Call it on every rank, since every rank's ``test`` reloads its own
``best_model``; the ``best.pt`` write self-gates to rank 0 on its own. A trainer
whose score only exists on rank 0 may call it under a rank guard, but then owes
:meth:`step_patience` a verdict broadcast to the other ranks.
Args:
score: The scalar being tracked, in ``val.minimize`` direction. A
non-finite score never counts as an improvement.
metrics: The epoch's val metrics, if they should be recorded too.
Returns:
True if the score improved, i.e. the weights were kept.
"""
if not self._best_tracking_initialized:
self.reset_best_tracking()
# NaN compares False against anything, so an unguarded NaN would read as an
# improvement and then win every later comparison as the incumbent best
if not math.isfinite(score):
return False
sign = -1 if self.val_minimize else 1
if sign * score <= sign * self.best_val:
return False
if metrics is not None:
self.best_val_metrics = {f"best/val/{k}": v for k, v in metrics.items()}
self.best_val_metrics["best/val/avg"] = score
self.best_metrics = self.best_val_metrics | {"best/epoch": self.epoch}
self.logger.log_dict(self.best_metrics)
self.best_val = score
self.best_epoch = self.epoch
# snapshot on the host: it is held for the whole run and only read at test time
self.best_model = {
k: v.detach().to("cpu", copy=True) if torch.is_tensor(v) else deepcopy(v)
for k, v in self.model.state_dict().items()
}
self._save_best_checkpoint()
return True
[docs]
def step_patience(self, improved: bool) -> bool:
"""Advance the early-stopping counter with this epoch's outcome.
An improvement refills the counter; otherwise it ticks down, but only once
the epoch reaches ``val.start_patience``.
Every rank has to reach the same verdict, or one leaves the epoch loop while the
others block in the next collective. That holds on its own when the score is
rank-invariant, i.e. a torchmetrics ``compute()`` or a :meth:`reduce_mean`
result; a rank-0-only score has to be broadcast by the caller instead.
Args:
improved: What :meth:`save_if_best` returned for this epoch.
Returns:
True once patience is exhausted, i.e. training should stop.
"""
if not self._best_tracking_initialized:
self.reset_best_tracking()
if improved:
self.patience = self._initial_patience
elif self.epoch >= self.start_patience:
self.patience -= 1
if self.patience <= 0:
self.logger.info(
f"Early stopping triggered at epoch {self.epoch}, best epoch was {self.best_epoch}"
)
return True
return False
[docs]
def get_param_groups(self, *modules: torch.nn.Module) -> list[dict]:
"""Split parameters into a weight-decayed and an undecayed optimizer group.
A parameter skips weight decay if it is 1-D (biases, norm and other scale
vectors) or if its name contains one of ``cfg.no_weight_decay``. The default list
covers biases, norms and every ``*_emb`` lookup table; a model overrides it only
when a name lies about what it is.
Args:
modules: Modules to take parameters from, ``self.model`` when none are
given. Pass more when parameters outside the model are trained by the
same optimizer, such as an objective with its own projection head.
"""
return param_groups(
*(modules or (self.model,)),
weight_decay=self.cfg.weight_decay,
no_weight_decay=self.cfg.get("no_weight_decay", DEFAULT_NO_WEIGHT_DECAY),
)
[docs]
def log_lr(self):
"""Log the current learning rate for each optimizer parameter group."""
for i, param_group in enumerate(self.optimizer.param_groups):
name = param_group.get("name", f"group_{i}")
self.logger.log(f"lr/{name}", param_group["lr"])
[docs]
def clip_and_log_grad_norm(self, *modules: torch.nn.Module) -> torch.Tensor:
"""Clip gradients in place and log the pre-clip norm as ``train/grad_norm``.
The norm is measured even when ``grad_clip`` is unset so the metric does not
vanish on unclipped runs. Call once per optimizer step, after ``backward()``.
Args:
modules: Modules to clip over, ``self.model`` by default. Pass the same ones
as :meth:`get_param_groups`, so the norm spans every parameter stepped.
"""
self.log_param_grad_stats(*modules)
max_norm = self.cfg.get("grad_clip")
grad_norm = torch.nn.utils.clip_grad_norm_(
chain.from_iterable(m.parameters() for m in (modules or (self.model,))),
max_norm=float("inf") if max_norm is None else max_norm,
)
if self.cfg.get("log_train_step", True):
self.logger.log("train/grad_norm", grad_norm.item(), pbar=False)
else:
self._grad_norms.append(grad_norm.item())
return grad_norm
[docs]
def log_epoch_grad_norm(self):
"""Log the epoch-mean grad norm, the counterpart to the epoch-mean train loss."""
if self._grad_norms:
self.logger.log("train/grad_norm", sum(self._grad_norms) / len(self._grad_norms))
self._grad_norms.clear()
[docs]
def log_param_grad_stats(self, *modules: torch.nn.Module):
"""Log per-parameter weight norm, grad norm, and grad-to-weight ratio.
Each gate costs one W&B key per parameter, so both are throttled to every
``log_stats_every_n_steps`` steps. Call after ``backward()``, before any clip.
"""
weights, grads = self.cfg.get("log_weights"), self.cfg.get("log_grads")
if not (weights or grads):
return
every_n = self.cfg.get("log_stats_every_n_steps", 1)
if every_n <= 0 or self.train_step % every_n:
return
named = [
(name.removeprefix("module."), param) # keys match across DDP and single-GPU
for module in (modules or (self.model,))
for name, param in module.named_parameters()
if param.requires_grad
]
if not named:
return
# one host sync, not one per parameter
zero = torch.zeros((), device=self.device)
norms = torch.stack(
[p.detach().norm().float() for _, p in named]
+ [zero if p.grad is None else p.grad.detach().norm().float() for _, p in named]
).tolist()
w_norms, g_norms = norms[: len(named)], norms[len(named) :]
stats = {}
for (name, param), w, g in zip(named, w_norms, g_norms, strict=True):
if weights:
stats[f"weight_norm/{name}"] = w
if grads and param.grad is not None:
stats[f"grad_norm/{name}"] = g
if w > 0:
stats[f"grad_weight_ratio/{name}"] = g / w
if weights:
stats["weight_norm/total"] = sum(w * w for w in w_norms) ** 0.5
self.logger.log_dict(stats)
[docs]
def push_logs(self):
"""Flush accumulated logs to W&B, including current epoch and step."""
self.logger.log("train/epoch", self.epoch)
self.logger.log("train/step", self.train_step)
self.logger.push()
[docs]
def get_best_pbar_metrics(self) -> dict:
"""Return the best validation metrics formatted for the epoch progress bar.
Returns:
dict: Best validation metrics with the ``best/val/`` prefix stripped,
or an empty dict if no validation has been run yet.
"""
if hasattr(self, "best_val_metrics"):
return {k.replace("best/val/", ""): v for k, v in self.best_val_metrics.items()}
return {}