How d9d Works
About
This page answers two questions. What does d9d do with the parts that you write? And why does the training loop need no code for your model or your parallelism? The page follows a training job from its parts and the model build, through one step, to its checkpoints and events. Read it after the Quickstart and before you write your own job.
Overview
configure() builds the job once. train() resumes from the latest training checkpoint, if one exists, and repeats one step until the end. The sections below follow this picture from top to bottom:
flowchart TB
subgraph configure ["configure()"]
direction LR
mesh["mesh config"] --> ctx["DistributedContext<br>with domains"]
ctx --> task["Task"]
ctx --> stream["Data stream<br>one pack per step"]
ctx --> stages["Model stages<br>meta device, parallelize,<br>allocate, reset_parameters(), load"]
stages --> optim["Optimizer and<br>LR scheduler"]
stages --> components["Loop components<br>gradient manager, clipper,<br>logger, checkpointer"]
end
subgraph train ["train()"]
direction TB
resume["Load the latest<br>training checkpoint"] --> pack["Pack of microbatches"]
pack --> inputs["build_forward_inputs()"]
inputs --> program["Program<br>forward, compute_loss(),<br>backward"]
program --> sync["Gradient sync<br>and scaling"]
sync --> clip["Clipping"]
clip --> opt["optimizer.step()<br>lr_scheduler.step()"]
opt --> log["Logging"]
log --> ckpt["Training checkpoint<br>on selected steps"]
ckpt -. "next step" .-> pack
end
configure --> train
The Parts of a Job
You write a few parts of a job, each behind an interface. d9d provides ready implementations of the others:
| Interface | What it decides | Who provides it |
|---|---|---|
| Data provider | The microbatches of each step. | AutoDataProvider with your dataset and collate function, or you. |
| Model | The forward pass, from the model inputs to the outputs. | You, or the Model Catalogue. The model provider builds it. |
| Task | The model inputs, the loss and the metrics. | You. |
| Model provider | The construction, distribution, loading and export of the model. | You. |
| Optimizer and LR scheduler providers | The update of the parameters. | AutoOptimizerProvider and AutoLRSchedulerProvider, or you. |
TrainingConfigurator takes these parts and two Pydantic configs:
configure() builds the job and returns a Trainer, and train() runs the training loop. The Auto* providers take Pydantic configs too, so the common cases need no code.
Device Mesh Domains
The first component that configure() builds is the DistributedContext. It describes the topology of the job: the ranks (one process per GPU), the device meshes over them and the process groups. The configurator builds it once from the mesh config and passes it to every component that needs it. No component reads the device mesh from global state.
Different submodules need different parallelism:
- Dense layers: FSDP shards them across the data-parallel ranks.
- MoE experts: Expert parallelism shards them across the expert-parallel ranks.
The context therefore arranges the same ranks into several device meshes, called domains. All domains contain the same ranks, and they differ only in how they group the ranks into named mesh dimensions. The model provider of the Quickstart takes the mesh of the dense domain:
Distributed Context lists the domains and their dimensions.
The Model Build
Pipeline parallelism splits a model into stages. Without pipeline parallelism, the whole model is one stage. Each rank builds only the stages that it hosts, and configure() builds each of them in this order:
- It calls
initialize_model_stage()of the model provider, which builds the model on themetadevice, without memory. - It calls
parallelize_model_stage()to distribute the parameters, unless the job runs without parallelism. - It allocates uninitialized GPU memory for the local shard of each parameter.
- It calls
reset_parameters()of the model to initialize the parameters. - It loads pretrained weights into the stage, if the config names a source.
Distribution comes before allocation. Each rank allocates only its local shard of each parameter, never a full copy first.
This build sets the contract of the model. Every model must implement two methods:
reset_parameters(): Initializes every parameter and buffer after the allocation.forward(inputs, shared): Runs the model on one microbatch.
With pipeline parallelism, the model must also:
- Build only the layers of its stage: The model provider reads the stage from
context.stageand builds the model for it. - Implement
stage_transfer_spec(): Describes the tensors that the stage receives from the previous stage and sends to the next one.
See Model Design and Pipeline Parallelism.
Parameter Placements
A distributed parameter records its own layout. It is a DTensor: a PyTorch tensor that carries the local shard of the rank and one placement per mesh dimension:
Shard: The ranks along that dimension hold slices of the tensor.Replicate: Each rank along that dimension holds a full copy.
The parallelize_* functions turn the parameters of a submodule into DTensors. In parallelize_model_stage(), you call them on submodules, each call with the mesh of a domain. For example, parallelize_hsdp() shards a submodule with PyTorch FSDP along one mesh dimension and replicates it along the others.
To add a strategy, write a function that sets the placements you need. Why the Loop Needs No Model or Parallelism Code shows what the loop does with them. See Horizontal Parallelism.
Steps and Packs
A step is one update of the parameters. The data provider returns a data stream that yields one pack per step. A pack contains all microbatches of that step on one rank.
The number of microbatches in a pack is the gradient accumulation factor. AutoDataProvider computes it as global_batch_size / (microbatch_size × the number of data-parallel ranks). A custom data provider can change the number from step to step.
The loop runs schedule.total_steps steps. If the config leaves this setting empty, the loop runs as many steps as the data stream reports.
Pipeline Programs
Gradient accumulation and pipeline parallelism are one mechanism in d9d. A pipeline schedule turns each pack into a program: the list of actions that each rank runs. An action is a forward or backward pass of a stage on one microbatch, or a transfer between two stages.
Without pipeline parallelism, a program has no transfers between stages. It contains only the forward and backward passes of the microbatches, which is gradient accumulation. A job with one rank skips the program and runs the forward and backward pass of each microbatch in turn.
With pipeline parallelism, the program also sends the activations of each microbatch to the next stage. The gradients of these activations come back the same way. The schedule sets the order of the actions. This is the program of the 1F1B schedule for two pipeline ranks and a pack of 4 microbatches, mb0 to mb3:
Each column lists the actions of one rank in order. The rows are not aligned in time, because a pass waits for the data that it needs. Rank 0 starts with several forward passes and then alternates. Rank 1, the last rank, alternates from the start. Other schedules, such as Zero Bubble, produce other programs, and the ranks run them the same way.
See Pipeline Parallelism and Pipelining Internals.
The Loss of a Step
The task turns each microbatch into model inputs, and each model output into a loss. Before the forward passes, the loop calls build_forward_inputs() of the task for each microbatch. This method returns three values:
input: The input of the model.shared: Data that every stage needs, such as the position IDs.state: What the loss needs besides the model output, such as the targets.
After the last stage, compute_loss() receives the model output as pipeline_results, together with the state of the same microbatch.
The task decides how much each microbatch counts. compute_loss() returns the loss and a weight, loss_weight, where None means a weight of 1. The backward pass starts from the loss multiplied by its weight. Before optimizer.step(), the gradient manager divides the gradients by the total weight over all microbatches and ranks. The loss of a step is then the weighted mean of the microbatch losses.
For a per-token loss, compute_loss() returns the mean loss of its microbatch and its number of tokens as the weight. The loss of the step is then the mean over all tokens on all ranks, even when microbatches differ in length. See User Tasks.
The Training Loop
The training loop only sets the order of the calls. TrainingConfigurator built every object in it and gave each one its collaborators, such as the DistributedContext or the model stages. train() first loads the latest training checkpoint, if one exists. Then it runs the step loop below. The excerpt leaves out the progress bar, the profiler, garbage collection, timeout updates and the end-of-data check:
Five components do the work of a step:
task_operator: Runs the forward and backward passes of the pack and calls the task for each microbatch.gradient_manager: Waits for the gradient sync, which sums the gradients across ranks and overlaps with the last backward passes. Then it divides the gradients by the total loss weight. Itszero_grad()clears them for the next step.gradient_clipper: Computes the global gradient norm after the gradient sync. It clips the gradients ifgradient_clipping.max_normis set.logger: Writes the loss and the gradient norm to the tracker. On logging steps, it also syncs the metrics across ranks and writes them.checkpointer: Saves a training checkpoint at the steps that thecheckpointingconfig selects.
The event_bus calls mark the points where your code can hook in (see Events). Training Loop describes the full lifecycle, from configuration to finalization.
Why the Loop Needs No Model or Parallelism Code
Each distributed parameter records its placements. The loop components read these placements instead of asking the model. A new model or a new strategy therefore needs no change in the loop:
- Gradient sync: The gradient manager sums each gradient over the mesh dimensions where its parameter is replicated. FSDP sums the gradients over its shard dimension itself.
- Gradient clipping: The clipper sums the squared norms of sharded gradients over the dimensions they are sharded along. A replicated gradient is the same on every rank and counts once. With pipeline parallelism, the clipper also sums over the pipeline ranks.
- Training checkpoints: Every rank writes its own shards. A replicated tensor is written once.
- Loading pretrained weights: The job shards each loaded tensor to match the placements of its parameter.
See Gradient Norm and Clipping.
Training Checkpoints and Model States
d9d uses two kinds of files. A training checkpoint resumes a job. A model state exchanges weights with other tools.
A training checkpoint is a PyTorch Distributed Checkpoint. It contains the model stages, the optimizer, the LR scheduler and the step counter. It also contains the position in the data stream, the metrics, the state of the experiment tracker and the state_dict() of the task. See Checkpointing.
A model state contains only weights, as .safetensors files. At the start, the job loads the model state that trainer.model_stage_factory.source_checkpoint points to. The setting names a model state, not a training checkpoint. Trainer.export() writes a model state.
The layout of a model state often differs from the layout of your model. For example, a Hugging Face model can store the query, key and value projections as three tensors, while your model keeps them in one. A graph of state mappers translates between the layouts:
Each mapper declares groups: sets of input tensors and the output tensors that they produce. The reader streams a model state through a mapper, one file at a time. It reads only the tensors that the mapper needs and frees them once their group is done. Memory holds the needed tensors of the current file, plus the inputs of groups that still wait for tensors in later files.
The model provider returns the loading mapper from initialize_model_stage(). It returns the export mapper from prepare_export_model_stage(). A PEFT method, such as LoRA, returns a mapper for the layers that it changes. See Model State Mapper and Model State I/O.
Events
Events let you add behavior to the loop from your own classes, without subclassing Trainer. The loop triggers typed events at fixed points: during configuration, around each step, around the forward and backward passes and around optimizer.step(). The task and the model provider subscribe to events in register_events().
Colocated RL, for example, hands the GPUs to a rollout engine after each step. The task gets the trainer once configure() returns it:
See Event Bus and Hooks and Sleep and Wake.
Where to Go Next
To build a job of your own, continue with Write Your Own Job. For forward-only runs over a dataset, see Inference Loop.