Inference and training times¶
This tutorial shows how to measure inference and training times for a saved FlashCart model on a fixed atomic structure. It adapts the CUDA-event timing procedure used for the FlashCart paper to a checkpoint and structure of your choice.
Inference includes energy and force evaluation. The training measurement includes prediction, loss evaluation, and differentiation with respect to the model parameters. The graph is built before timing, and both the graph and model are transferred to the GPU. Data loading, neighbor-list construction, device transfers, and optimizer updates are excluded.
Run the example¶
The example requires an NVIDIA GPU, a checkpoint in the run’s best/ directory,
and a structure containing elements supported by the checkpoint. Training
measurements also require the run’s config.yaml. You can reuse the run and
test.extxyz from Quickstart. The example uses models trained with
energies in eV and positions in Å. Its synthetic training targets support loss
functions on energies and forces.
Download measure.py into the
directory containing your training run and structure file, then run:
CUDA_VISIBLE_DEVICES=0 python measure.py models/my_run test.extxyz
The script is also available at examples/timing/measure.py in the FlashCart
repository. Paths are resolved from the directory where you
run the command.
Replace 0 with the GPU index for your run. The script reads the first structure
in the file, loads models/my_run/best, and measures both inference and training.
Use --phase inference or --phase training to measure only one phase.
The training loss comes from models/my_run/config.yaml.
The script uses float32 with TF32 disabled and keeps the checkpoint’s tensor
backend. Predictions are evaluated without torch.compile by default. To
measure compiled prediction, run the script again with:
CUDA_VISIBLE_DEVICES=0 python measure.py models/my_run test.extxyz \
--compile-mode reduce-overhead
Compilation applies to prediction. The training measurement also includes the
loss and its backward pass. Each phase has its own warmup. See
Kernel generation and compilation for the distinction between generated
Triton kernels and torch.compile. Use --compile-mode default to match the
setting described under Compiled evaluation in LAMMPS interface.
The LAMMPS interface does not support CUDA-graph modes. The inference measurement
excludes neighbor-list construction, communication, and integration, which also
contribute to the time required for a complete simulation.
What is timed¶
The same graph is reused for every evaluation. Each measured call performs the following work:
Phase |
Computation |
|---|---|
Inference |
Evaluate the energy and differentiate it to obtain forces. |
Training |
Clear parameter gradients, evaluate the properties required by the loss, compute the loss, and differentiate it with respect to the model parameters. |
Inference disables parameter gradients but keeps autograd enabled to compute
forces. Training keeps the derivative graph with create_graph=True so that
the force loss can be differentiated with respect to model parameters. The
weights remain unchanged because no optimizer update is performed.
Before timing training, the script creates synthetic targets from the model’s predictions plus fixed Gaussian noise. The standard deviation is \(10^{-3}N\) eV for the total energy of an \(N\)-atom structure and \(10^{-2}\) eV/Å for each force component. The random seed is zero. These targets allow the loss and its parameter gradients to be computed without reference calculations. They are used only for timing, not for assessing prediction accuracy. They replace any reference labels only in the in-memory graph.
How time is measured¶
By default, each phase runs 10 untimed warmup evaluations followed by 50 measured evaluations. CUDA events surround each measured call. Synchronization before and after the call ensures that earlier GPU work has finished and that the measurement is complete before its elapsed time is read.
Warmup allows initial compilation and autotuning to take place before timing.
Increase --warmup if initial work still affects the measurements, and increase
--repeat to collect more samples.
The script reports the mean and sample standard deviation in milliseconds per evaluation and microseconds per atom. Time per atom is the measured time divided by the number of atoms in the structure. The standard deviation describes variation between repeated evaluations of this graph, not variation between training runs.
The script also prints the checkpoint, GPU, software versions, tensor backend, compilation setting, cutoff, and numbers of atoms and edges. Keep these settings with the measurements. Time per atom depends on system size and the number of neighbors. Use the same structure when comparing models.
Timing script
"""Measure CUDA inference and training computations on one atomic structure.
python examples/timing/measure.py models/my_run test.extxyz
The example uses the best checkpoint and synthetic training targets. Timings exclude
structure reading, neighbor-list construction, device transfers, and optimizer updates.
"""
import argparse
import os
from importlib.metadata import version
from pathlib import Path
from statistics import mean, stdev
os.environ["NVIDIA_TF32_OVERRIDE"] = "0"
os.environ["TORCH_ALLOW_TF32_CUBLAS_OVERRIDE"] = "0"
import torch
import yaml
from ase.io import read
from flashcart.data.graph import graph_from_ase
from flashcart.model.flashcart import FlashCartPotential
from flashcart.training.loss import loss_from_config
def prepare_step(model, graph, phase, train_loss=None, compile_mode=None):
"""Prepare a repeated prediction or training computation.
Training targets are detached predictions plus seeded Gaussian noise, with
standard deviations of 0.001 eV per atom for energy and 0.01 eV/Å for forces.
The supplied model and graph are updated in place.
Args:
model (FlashCartPotential): Model already on the selected device.
graph (AtomicData): Graph already on the same device as the model.
phase (str): ``"inference"`` or ``"training"``.
train_loss (LossFunction, optional): Required for training. Only energy and
force losses are supported. Default: None.
compile_mode (str, optional): Prediction compilation mode. Default: None,
which leaves prediction uncompiled.
Returns:
Callable: No-argument function returning predictions or a scalar training
loss. Training clears parameter gradients before each computation.
"""
if phase not in ("inference", "training"):
raise ValueError("phase must be 'inference' or 'training'.")
training = phase == "training"
if training:
if train_loss is None:
raise ValueError("Training requires a loss function.")
keys = train_loss.compute_keys
if keys["compute_stress"] or keys["compute_virials"]:
raise ValueError("This example supports energy and force losses, not stress or virial losses.")
model.train(training)
model.requires_grad_(training)
model.zero_grad(set_to_none=True)
options = {
"compute_forces": train_loss.compute_keys["compute_forces"] if training else True,
"use_compile": compile_mode is not None,
"compile_mode": compile_mode or "reduce-overhead",
}
if training:
with torch.enable_grad():
reference = model.predict(graph, compute_forces=options["compute_forces"])
generator = torch.Generator(device=graph.positions.device).manual_seed(0)
energy = reference["energy"].detach()
graph.energy = energy + 0.001 * graph.n_atoms.to(energy) * torch.randn(
energy.shape, generator=generator, device=energy.device, dtype=energy.dtype
)
if options["compute_forces"]:
forces = reference["forces"].detach()
graph.forces = forces + 0.01 * torch.randn(
forces.shape, generator=generator, device=forces.device, dtype=forces.dtype
)
del reference
def step():
model.zero_grad(set_to_none=True)
prediction = model.predict(graph, create_graph=True, **options)
loss = train_loss.training_loss(prediction, graph, world_size=1)
loss.backward()
return loss
else:
def step():
return model.predict(graph, **options)
return step
def measure(step, device, warmup=10, repeat=50):
"""Measure individual calls with CUDA events after untimed warmup.
Args:
step (Callable): No-argument function performing the measured computation.
device (torch.device): CUDA device used by the computation.
warmup (int, optional): Number of untimed calls. Default: 10.
repeat (int, optional): Number of measured calls. Default: 50.
Returns:
list[float]: Elapsed CUDA-event time in milliseconds for each measured call.
"""
start = torch.cuda.Event(enable_timing=True)
end = torch.cuda.Event(enable_timing=True)
times = []
with torch.cuda.device(device), torch.enable_grad():
for _ in range(warmup):
output = step()
del output
torch.cuda.synchronize(device)
for _ in range(repeat):
torch.cuda.synchronize(device)
start.record()
output = step()
end.record()
torch.cuda.synchronize(device)
times.append(start.elapsed_time(end))
del output
return times
def main():
"""Load a checkpoint and report inference or training timings on CUDA."""
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("run_dir", type=Path, help="training output directory containing best/ and config.yaml")
parser.add_argument("structure", type=Path, help="structure file readable by ASE (only the first frame is used)")
parser.add_argument("--phase", choices=("inference", "training", "both"), default="both")
parser.add_argument("--warmup", type=int, default=10)
parser.add_argument("--repeat", type=int, default=50)
parser.add_argument(
"--compile-mode",
choices=("default", "reduce-overhead", "max-autotune", "max-autotune-no-cudagraphs"),
default=None,
help="enable torch.compile for prediction using this mode",
)
args = parser.parse_args()
if args.warmup < 0 or args.repeat < 2:
parser.error("--warmup must be nonnegative and --repeat must be at least two.")
if not torch.cuda.is_available():
parser.error("CUDA is required for this example. Select a GPU with CUDA_VISIBLE_DEVICES.")
torch.manual_seed(0)
torch.set_default_dtype(torch.float32)
torch.backends.cuda.matmul.allow_tf32 = False
torch.backends.cudnn.allow_tf32 = False
torch.set_float32_matmul_precision("highest")
device = torch.device("cuda:0")
checkpoint = args.run_dir / "best"
model = FlashCartPotential.from_checkpoint(checkpoint, device=device).float()
atoms = read(args.structure, index=0)
graph = graph_from_ase(atoms, model.elements, model.r_max, device=device)
if len(atoms) == 0 or graph.edge_index.shape[1] == 0:
parser.error("The structure must contain atoms and neighbors within the model cutoff.")
train_loss = None
if args.phase != "inference":
config = yaml.safe_load((args.run_dir / "config.yaml").read_text())
train_loss = loss_from_config(config["train_loss"])
if train_loss.compute_keys["compute_stress"] or train_loss.compute_keys["compute_virials"]:
parser.error("This example supports energy and force losses, not stress or virial losses.")
print(f"checkpoint: {checkpoint.resolve()}")
print(f"structure: {args.structure.resolve()} (first frame)")
print(f"GPU: {torch.cuda.get_device_name(device)}")
print(f"software: FlashCart {version('flashcart')}, PyTorch {torch.__version__}, CUDA {torch.version.cuda}")
print(f"dtype: {next(model.parameters()).dtype}, TF32: disabled")
print(f"backend: {'Triton' if model.use_triton else 'PyTorch'}, compile: {args.compile_mode or 'disabled'}")
print(f"system: {len(atoms)} atoms, {graph.edge_index.shape[1]} edges, cutoff {model.r_max:g} Å")
print(f"calls: {args.warmup} warmup, {args.repeat} measured per phase")
phases = ("inference", "training") if args.phase == "both" else (args.phase,)
for phase in phases:
step = prepare_step(model, graph, phase, train_loss, args.compile_mode)
samples = measure(step, device, args.warmup, args.repeat)
average = mean(samples)
deviation = stdev(samples)
print(
f"{phase}: {average:.6f} ± {deviation:.6f} ms/call, "
f"{average * 1000 / len(atoms):.6f} ± {deviation * 1000 / len(atoms):.6f} µs/atom "
"(mean ± sample standard deviation)"
)
if __name__ == "__main__":
main()
The diamond benchmark¶
The model benchmarks in the FlashCart paper use periodic diamond cells with a lattice constant of 3.567 Å. For a checkpoint that supports carbon, prepare a 1000-atom cell with:
from ase.build import bulk
from ase.io import write
atoms = bulk("C", "diamond", a=3.567, cubic=True).repeat((5, 5, 5))
write("diamond.extxyz", atoms)
Pass diamond.extxyz in place of test.extxyz. A (4, 4, 4) repeat produces
512 atoms, the size used for the training comparison between tensor backends.
Other benchmarks use different system sizes and compilation settings. To
reproduce a published result, also match its checkpoint, backend, hardware,
and software versions.
Profiling dataset batches¶
For operation tables and traces, use Performance and profiling.
The flashcart-profile command initializes a new model and measures elapsed
wall time over selected dataset batches. This tutorial instead loads a saved
checkpoint and measures individual evaluations of one graph with CUDA events.
Both exclude optimizer updates, but their reported times describe different
workloads and measurement procedures.