Training Loop and Checkpointing
The pretraining path in llm/training.py is intentionally compact, but it covers the full execution lifecycle from process-group setup to checkpoint saving. This chapter traces the actual control flow.
Configuration Boundary
The public entrypoint is train(). It pulls all hyperparameters from llm.args, asserts that the batch size is divisible by world_size, prepares checkpoint and log directories, and then launches:
mp.spawn(_train, args=(args.world_size, args.backend, args), nprocs=args.world_size, join=True)
So each rank runs the same _train() function with shared configuration and a different rank id.
Reproducibility Path
set_random_seed(seed, rank) derives a rank-specific seed by adding the rank to the global seed, then seeds:
torchtorch.cudanumpy- Python
random
It also forces:
torch.backends.cudnn.deterministic = Truetorch.backends.cudnn.benchmark = False
This is a practical compromise: per-rank seeds differ, but runs remain reproducible given a fixed world size and launch order.
Batch Construction
get_batch() is the main data loader primitive. It operates on a token-id array x and samples random start indices:
ix = torch.randint(0, len(x) - context_length, (batch_size,))
For each start index, it creates:
input_seqs = x[i : i + context_length]target_seqs = x[i + 1 : i + 1 + context_length]
This is the exact autoregressive next-token objective. Inputs and targets differ only by a one-token shift.
Two operational details matter:
- the arrays can be memory-mapped with
mmap_mode="r" - the tensors are moved to the requested device before returning
So the training loop never materializes the entire dataset on GPU.
Process-Group Setup
_setup_process_group() owns the multi-GPU runtime bootstrapping:
- sets
MASTER_ADDR=localhost - sets
MASTER_PORT=12390 - sets
NCCL_DEBUG=NONE - chooses a local CUDA device as
rank % device_count - calls
dist.init_process_group(backend, rank=rank, world_size=world_size)
This is a local training script rather than a cloud launcher abstraction. The setup is explicit enough that the reader can see exactly how ranks become distributed workers.
_cleanup_process_group() then inserts a final barrier before destroying the process group, which prevents ranks from tearing down communication state out of order.
Model Construction
Inside _train(), the script constructs:
model = Transformer(
d_model=args.d_model,
num_heads=args.num_heads,
d_ff=args.d_ff,
vocab_size=args.vocab_size,
num_layers=args.num_layers,
max_seq_len=args.max_seq_len,
device=device,
)
When world_size > 1, the model is immediately wrapped in the custom DDP implementation from parallel/ddp.py.
That wrapper is not cosmetic. It changes how gradients are synchronized later in the loop.
Optimizer Selection
The script chooses optimizers based on world size:
- single rank: custom
AdamW - multi-rank:
ShardedOptimizer(..., AdamW, ...)
This means the higher-level training logic stays identical while optimizer-state ownership changes under the hood.
The loss is always the custom CrossEntropyLoss.
Validation Path
Validation is only run on rank 0:
- switch model to eval mode
- run 100 validation batches
- average the scalar loss
- print it
- log it to TensorBoard
- switch back to train mode
That choice keeps logging centralized and avoids duplicated validation output from every rank.
The validation path is intentionally simple: no distributed metric reduction, just one reference rank evaluating the current model snapshot.
Main Training Step
Each iteration performs the following sequence:
- fetch a training batch
- forward pass through the model
- compute cross-entropy loss
optimizer.zero_grad()loss.backward()gradient_clip(model.parameters(), max_norm=1.0)- if distributed,
model.finish_gradient_sync() optimizer.step()
The explicit finish_gradient_sync() call is important. The custom DDP path uses asynchronous bucketed all-reduce, so synchronization is not magically complete at the moment backward() returns. The training script makes the completion point visible.
Learning-Rate Schedule
After the parameter update, the script computes a fresh learning rate with:
cos_lr_scheduler(
it=i,
warmup_iters=args.warmup_iters,
cos_cycle_iters=args.cos_cycle_iters,
lr_min=args.lr_min,
lr_max=args.lr_max,
)
The resulting value is written into every optimizer param group.
This keeps the schedule external to the optimizer implementation and makes it trivial to inspect or replace.
Logging
On rank 0, the script writes:
loss_trainlrval_loss
to SummaryWriter, and also prints periodic console logs every log_interval.
So the monitoring surface is minimal but sufficient for:
- training stability
- schedule inspection
- basic overfit/divergence detection
Checkpoint Format
Checkpointing happens every checkpoint_interval after iteration 0:
save_checkpoint(model, optimizer, i, path)
llm/checkpoint.py writes a dictionary containing:
modeloptimizeriteration
This is intentionally small and portable. There is no extra trainer metadata layer.
Checkpoint Consumption
llm/generating.py consumes that format directly:
- rebuild the same
Transformer - call
load_checkpoint(...) - load tokenizer state
- encode a prompt
- repeatedly crop to
model.max_seq_len - compute logits for the last position
- apply temperature
- apply top-p truncation
- sample one token
- stop on the end-of-text token
This connection is important because it proves the training loop’s artifacts are enough for inference without any additional conversion step.
Distributed Variant in Practice
When world_size > 1, two things change operationally:
- each rank uses
mini_batch_size = args.batch_size // world_size - model/optimizer synchronization paths become active
But the semantic training objective remains identical: the model still learns next-token prediction over random fixed windows from the same tokenized corpus.
What the File Optimizes For
llm/training.py is not feature-rich by framework standards. It is optimizing for clarity of ownership:
- one function for batch slicing
- one function for process-group setup
- one function per-rank training body
- one top-level launcher
- one checkpoint format
That makes it easy to answer practical implementation questions such as:
- where the dataset enters GPU memory
- when gradients are clipped
- when distributed communication is forced to complete
- when LR changes
- what exactly gets serialized
For a repository that aims to teach model-building mechanics, that directness is more valuable than a larger but more abstract trainer stack.