Skip to content

Training

StableTested and in active use. Breaking changes will be called out. CUDA-graph capture in the nn Trainer is off by default — see below.

Trainer is the supervised training loop: classification and regression on a dataset, distinct from the RL drivers in deep_agents/training.

from noeira.nn.training.trainer import Trainer
Trainer[
MODEL: Module,
NC: Int, # number of classes / output width
IN: Int, # input dimension
BATCH: Int,
target: StaticString, # "cpu" | "gpu"
POLICY: AMPPolicy = NoAMP,
OPT: Optimizer = Adam,
USE_TRAIN_CUDA_GRAPH: Bool = False,
]

Construct with make, passing the initializer:

var trainer = Trainer[Net, NC, IN_DIM, BATCH, "gpu"].make[Kaiming](
Optional(ctx), lr=1e-3
)

The trainer owns the model’s parameters, the optimizer state and the loss.

train_gpu uploads the dataset to the device once and runs the entire training loop on-device, slicing each batch as a zero-copy sub-buffer view:

var result = trainer.train_gpu[MNIST.N_TRAIN, MNIST.N_TEST](
ds.train_images, train_y, # training inputs, one-hot targets
ds.test_images, ds.test_labels,
Optional(ctx),
epochs=5,
shuffle=True, # on-device per-epoch shuffle
)
for acc in result.epoch_test_top1:
print(acc)

No per-batch host-to-device transfer. The shuffle is a device kernel, so it does not force a round trip either.

For finer control there is train_epoch and eval_top1.

Set the POLICY parameter to run in bf16:

var trainer = Trainer[Net, NC, IN_DIM, BATCH, "gpu", BF16Policy].make[Kaiming](
Optional(ctx), lr=1e-3
)

The design is bf16-only true autocast — no loss scaling, because bf16’s exponent range makes it unnecessary. Activations are stored at the flow dtype, which is where the memory saving comes from: a module declares comptime ACT_DT = bfloat16 and combinators derive theirs from their children, typing inter-module buffers accordingly.

Casts happen only at boundaries. The fp32 path (ACT_DT == DT) is the existing code with no casts and no extra buffers — bit-identical to a non-AMP build.

Terminal window
pixi run -e nvidia mojo run -I . examples/nn/mlp/mlp_mnist_bf16_amp_gpu.mojo
pixi run -e nvidia mojo run -I . examples/nn/resnet/resnet20_cifar10_bf16_amp_gpu.mojo

USE_TRAIN_CUDA_GRAPH=True captures the per-batch device compute — batch gather, zero-grad, forward, loss accumulate, vjp, optimizer step — as a graph and replays it once per batch. The gather reads its offset from a device counter, so one capture serves every batch of the epoch. Per-epoch shuffling and augmentation run eagerly, in place, before the replays.

An eager run and a captured run of the same seed give equal losses and top-1 every epoch and byte-identical checkpoints (tests/nn/test_trainer_capture_shuffle_aug.mojo, with real replays on an RTX 5090).

TrainerPurpose
AutoregressiveTrainersequence models — GPT on TinyShakespeare
DistributedAutoregressiveTrainerthe same over N ranks, DDP or ZeRO-1/2/3 — see Distributed training
Augmenterdata augmentation for image training

AutoregressiveTrainer builds its batches on the device (DEVICE_BATCH=True, the default). The corpus is resident as int32. One kernel draws each row’s window start from a hash of (seed, step, row) and writes the input and target one-hots, so under USE_TRAIN_CUDA_GRAPH the batch build is inside the graph. DEVICE_BATCH=False keeps the host path as the baseline.

Each nn example has a PyTorch twin in tools/nn/torch_nn_reference.py: same recipe, batch size and data files, with the data resident on the GPU as ours is. It runs eager, under torch.compile, or captured in CUDA graphs (reduce-overhead), with TF32 on and fused Adam. Time per training step on one RTX 5090:

Modelnoeira (device batch + CUDA graph)PyTorch 2.12, compiled + CUDA graphs
TinyShakespeare GPT, fp3219.40 ms21.35 ms
TinyShakespeare GPT, bf16 AMP10.74 ms10.81 ms
ResNet-20, CIFAR-102.84 ms2.84 ms
CIFAR CNN1.35 ms1.33 ms

The GPT benchmark is examples/nn/benchmarks/gpt_cuda_graph_benchmark_gpu.mojo (-D GPT_BENCH_DTYPE=bf16 for the AMP column). Final validation losses over three seeds agree within seed noise: fp32 GPT 1.824 / 1.847 / 1.824 with the TF32 attention, against 1.836 / 1.837 / 1.828 with the fp32 kernels.

from noeira.nn.datasets import MNIST, CIFAR10, TinyShakespeare
DatasetNotes
MNIST60k train / 10k test, 28×28 grayscale
CIFAR1050k / 10k, 32×32 colour — Hugging Face parquet by default
TinyShakespearecharacter-level text
lewm_pushtPushT demonstrations, for the world-model work

Datasets download on first use and cache locally.

These are supervised data — images plus labels, or a token stream — and they stay here rather than moving to the trajectory store on purpose: they have no episode axis, so they are not trajectories. Episodic data (offline RL datasets, demonstrations, replay dumps) belongs in the data layer instead.

Terminal window
# MLP
examples/nn/mlp/mlp_mnist_training_storage_gpu.mojo
examples/nn/mlp/mlp_mnist_training_storage_cpu.mojo
# convolutional
examples/nn/conv2d/conv2d_mnist_training_storage_gpu.mojo
examples/nn/resnet/resnet20_cifar10_training_storage_gpu.mojo
# transformer
examples/nn/transformer/gpt_tinyshakespeare_training_gpu.mojo
examples/nn/vit/vit_cifar_training_storage_gpu.mojo