Skip to content

Pipeline Parallelism

The d9d Approach

d9d implements a modern, highly modular pipelining engine designed for performance, stability and customization.

Dynamic Shapes & Algorithmic Shape Inference

To run P2P (Point-to-Point) communication, the receiver must know the shape of the incoming tensor to pre-allocate buffers. d9d asks your model to implement a lightweight protocol (ModuleSupportsPipelining) to calculate the shape of the payload transferred between stages mathematically, without performing a heavy forward pass or doing a distributed graph tracing.

This allows supporting Dynamic Shapes (e.g., varying sequence lengths) efficiently across runs.

Construction Consistency (No Patching)

A common anti-pattern in distributed training is "Instantiate-then-Delete": creating a huge model on CPU/Meta device and then hacking it apart del model.layers[N:].

We reject this pattern because of:

  1. Fragility: Changes to model architecture require changes to the external slicing script.
  2. Leaky Abstractions: Forward methods become full of if self.layer is not None.
  3. Invalid States: The model object exists in a "zombie" state until sliced.

In d9d, models are Pipeline-Aware. Each pipeline rank constructs only the sub-graph it owns. The object returned is compliant, complete, and valid immediately.

Making Models Compatible

The Four IO Roles

A pipelined model moves data across stage boundaries as four explicitly-named, generic PyTree types (dataclasses are the recommended form):

Role Meaning Crosses P2P?
PipelineInput Input to the first stage (built by the task) no
StageTransfer Payload between adjacent stages (out of N == in of N+1) yes
PipelineOutput Output of the last stage (to loss / result callback) no
SharedInput Value passed to every stage, rebuilt locally per rank no

Only StageTransfer crosses the wire, so it is the only role that needs a TensorSpec.

The Protocol

To use Pipeline Parallelism, your model implements d9d.pipelining.api.ModuleSupportsPipelining[TPipelineInput, TStageTransfer, TSharedInput, TPipelineOutput]:

  • forward(inputs, shared)inputs is the PipelineInput on the first stage and the incoming StageTransfer otherwise; it returns the outgoing StageTransfer on non-last stages and the PipelineOutput on the last stage. The stage knows its position from the PipelineStageInfo it received at construction, so it branches on is_current_stage_first / is_current_stage_last explicitly.
  • stage_transfer_spec(pipeline_input, boundary) — returns a PyTree structurally identical to StageTransfer with every tensor leaf replaced by a TensorSpec. The boundary (StageBoundary.incoming / outgoing) selects which inter-stage edge to size.

Because stage N's outgoing transfer and stage N+1's incoming transfer are the same dataclass type, pytree.tree_flatten yields identical leaf orderings on both ends. Sender and receiver therefore agree on the wire order by construction — no name/shape handshake is needed.

Example

Below is a skeleton of a Transformer-like model implemented for d9d pipelining.

import dataclasses
import torch
from torch import nn
from d9d.pipelining.api import (
    ModuleSupportsPipelining,
    PipelineStageInfo,
    StageBoundary,
    TensorSpec,
    distribute_layers_for_pipeline_stage,
)


@dataclasses.dataclass
class MyInput:              # PipelineInput
    input_ids: torch.Tensor


@dataclasses.dataclass
class MyTransfer:           # StageTransfer (the only role crossing P2P)
    hidden_states: torch.Tensor


@dataclasses.dataclass
class MyShared:             # SharedInput (broadcast to every stage)
    position_ids: torch.Tensor


@dataclasses.dataclass
class MyOutput:             # PipelineOutput
    logits: torch.Tensor


class MyModelChunk(
    nn.Module,
    ModuleSupportsPipelining[MyInput, MyTransfer, MyShared, MyOutput],
):
    def __init__(self, stage: PipelineStageInfo, config):
        super().__init__()
        self.stage = stage
        self.config = config

        # 1. Determine what layers live here
        self.start_layer, self.end_layer = distribute_layers_for_pipeline_stage(
            config.n_layers, num_virtual_layers_pre=1, num_virtual_layers_post=1, stage=stage
        )

        # 2. Build sub-modules (using ModuleDict - for compatibility)
        self.layers = nn.ModuleDict({
            str(layer): TransformerBlock(...)
            for layer in range(self.start_layer, self.end_layer)
        })

        # Only build embeddings on first stage
        if stage.is_current_stage_first:
            self.embed = nn.Embedding(...)

        # Only build head on last stage
        if stage.is_current_stage_last:
            self.head = nn.Linear(...)

    def forward(self, inputs: MyInput | MyTransfer, shared: MyShared) -> MyTransfer | MyOutput:
        # Branch on the stage position, never on which argument is None.
        if self.stage.is_current_stage_first:
            x = self.embed(inputs.input_ids)
        else:
            x = inputs.hidden_states

        # Run local layers
        for layer_idx in range(self.start_layer, self.end_layer):
            x = self.layers[str(layer_idx)](x)

        # Last stage produces the PipelineOutput; everyone else produces a StageTransfer.
        if self.stage.is_current_stage_last:
            return MyOutput(logits=self.head(x))
        return MyTransfer(hidden_states=x)

    # --- Protocol Implementation ---
    # Receives a single representative microbatch of PipelineInput (not a global batch), so shapes are
    # used as-is, and returns a StageTransfer of TensorSpec descriptors — no tensors are allocated.
    # The engine never calls this for the first stage's `incoming` nor the last stage's `outgoing`
    # edge, so terminal boundaries need no special-casing.
    def stage_transfer_spec(self, pipeline_input: MyInput, boundary: StageBoundary) -> MyTransfer:
        micro_batch_size, seq_len = pipeline_input.input_ids.shape
        return MyTransfer(
            hidden_states=TensorSpec(
                shape=(micro_batch_size, seq_len, self.config.hidden_dim), dtype=torch.bfloat16
            )
        )

Using the Pipeline

Supported Schedules

Example JSON Description
{"schedule": "inference"} Configuration for inference-only pipeline execution. Runs all forward passes sequentially without any backward passes.
{"schedule": "gpipe"} Standard GPipe execution. Assumes a single stage per rank and processes all microbatches for the forward pass before switching to the backward pass.
{"schedule": "looped_bfs", "num_stages_per_rank": 2} Looped Breadth-First Search execution. Supports multiple stages per rank (virtualization) and executes all work for a specific stage before moving to the next.
{"schedule": "1f1b", "num_stages_per_rank": 1, "zero_bubble": true} Interleaved 1F1B and Interleaved Zero Bubble execution. Supports multiple stages per rank. Handles sharding backward passes to dI and dW when zero_bubble is enabled.
{"schedule": "zero_bubble_v"} Zero Bubble V (ZBV) execution. A specialized V-shape topology schedule that splits backward passes into Input and Weight gradients. Requires exactly 2 stages per rank.
{"schedule": "dual_pipe_v"} DualPipeV execution. A bidirectional pipeline schedule for high-throughput training using V-shape topology and reciprocal forward/backward scheduling.

Microbatches and packs

Pipelining consumes a pack: a sequence of ready microbatches for one step. The pack length (and therefore the batch size) may vary from step to step. Buffers are sized per microbatch — each stage infers shapes for every microbatch in the pack independently — so the microbatches within a single pack may also differ in shape. The schedule recompiles its program when the microbatch count changes and reallocates buffers when any microbatch's shape changes.

Usage within the Trainer

Pipelining is available in the Trainer framework. When configuring the Trainer, simply provide an AnyPipelineScheduleConfig in your training arguments. The Trainer handles the construction of the schedule and the distribution of layers automatically.

Advanced - Manual Usage

If you want to use pipelining outside the Trainer (e.g., custom loops), you use the build_schedule factory.

The build_schedule function requires a Model Provider logic. Instead of passing an instantiated model, you pass a function that accepts PipelineStageInfo and returns the nn.Module for that stage. This ensures construction consistency.

from torch import Tensor
import torch.nn.functional as F

from d9d.core.dist_context import DistributedContext
from d9d.pipelining.factory import build_schedule, PipelineSchedule1F1BConfig


# 0. Define an object that manages loss calculation per microbatch. It receives the PipelineOutput
#    produced by the last stage and reads its fields by attribute.
class MyLossHandler:
    def __init__(self, targets_microbatches: list[Tensor]):
        self._targets = targets_microbatches

    def compute_loss(self, outputs: MyOutput, microbatch_idx: int):
        # Implement any custom logic here
        current_target = self._targets[microbatch_idx]
        return F.cross_entropy(outputs.logits.view(-1, outputs.logits.shape[-1]), current_target.view(-1))


# 1. Define configuration
dist_context: DistributedContext = ...
model_config = ...
schedule_config = PipelineSchedule1F1BConfig(
    num_stages_per_rank=4,  # 4 Virtual stages per rank
    zero_bubble=True  # Enable ZB1P optimization
)

# 2. Build the per-microbatch inputs (the "pack"). The number of microbatches is decided here, per
#    step. Each stage receives one PipelineInput and one SharedInput per microbatch.
inputs_microbatches = tuple(MyInput(input_ids=mb) for mb in my_input_microbatches)
shared_microbatches = tuple(MyShared(position_ids=pos) for pos in my_position_microbatches)
targets_microbatches = [...]  # one target tensor per microbatch

# 3. Build the schedule and model shards (the callback is supplied per step, not here)
loss_handler = MyLossHandler(targets_microbatches)
schedule_info, modules = build_schedule(
    dist_context=dist_context,
    schedule_config=schedule_config,
    model_provider=lambda stage: MyModelChunk(stage, model_config),  # Factory function
)

# 4. Execution
# The callback is passed to each step, so it can close over per-step data (here, the targets).
schedule_info.schedule.step(
    inputs_microbatches=inputs_microbatches,
    shared_microbatches=shared_microbatches,
    callback=loss_handler.compute_loss,
)

d9d.pipelining.api

Pipelining API that is intended to be accessible by end user.

PipelineLossFn = Callable[[TPipelineOutput, int], torch.Tensor] module-attribute

Callback function type for calculating loss in the final pipeline stage.

Parameters:

Name Type Description Default
outputs

The PipelineOutput PyTree produced by the last stage.

required
microbatch_idx

The index of the current micro-batch being processed.

required

Returns:

Type Description

The computed loss tensor (scalar).

PipelineResultFn = Callable[[TPipelineOutput, int], Any] module-attribute

Callback function type for handling results from a final pipeline stage.

Parameters:

Name Type Description Default
outputs

The PipelineOutput produced by the last stage.

required
microbatch_idx

The index of the current micro-batch being processed.

required

Returns:

Type Description

Anything - not used.

ModuleSupportsPipelining

Bases: Protocol[TPipelineInput, TStageTransfer, TSharedInput, TPipelineOutput]

Protocol for modules that can be split across pipeline stages.

A pipelined module carries four distinct IO roles, each an arbitrary PyTree (dataclasses are the recommended form). The module knows its position from the PipelineStageInfo it receives at construction and branches on it explicitly.

Class Type Parameters:

Name Bound or Constraints Description Default
TPipelineInput

Input consumed by the first stage.

required
TStageTransfer

Payload transferred between adjacent stages. The outgoing transfer of stage N and the incoming transfer of stage N+1 are the same type.

required
TSharedInput

Value passed to every stage's forward.

required
TPipelineOutput

Output produced by the last stage.

required

forward(inputs, shared)

Runs this stage.

Parameters:

Name Type Description Default
inputs TPipelineInput | TStageTransfer

PipelineInput on the first stage; StageTransfer otherwise.

required
shared TSharedInput

The value broadcast to every stage.

required

Returns:

Type Description
TStageTransfer | TPipelineOutput

PipelineOutput on the last stage; StageTransfer otherwise.

stage_transfer_spec(pipeline_input, boundary)

Describes the StageTransfer crossing the given boundary of this stage.

The returned PyTree is structurally identical to the StageTransfer itself, with every tensor leaf replaced by its TensorSpec. Shapes are derived by cheap arithmetic on pipeline_input; the forward body is never executed.

The engine only calls this for boundaries that actually transfer, deciding terminality from the pipeline topology — it is never called for the first stage's incoming edge nor the last stage's outgoing edge. Implementations therefore do not need to special-case terminal boundaries.

Parameters:

Name Type Description Default
pipeline_input TPipelineInput

A representative single microbatch of pipeline input. Only shapes and dtypes are read; values are never used.

required
boundary StageBoundary

incoming (received from the previous stage) or outgoing (sent to the next stage).

required

Returns:

Type Description
PyTree[TensorSpec]

A PyTree of TensorSpec for the transfer crossing that boundary.

PipelineSchedule

Bases: ABC, Generic[TPipelineInput, TSharedInput, TPipelineOutput]

Abstract base class defining the interface for pipeline execution schedules.

Class Type Parameters:

Name Bound or Constraints Description Default
TPipelineInput

The PipelineInput fed to the first stage.

required
TSharedInput

The SharedInput fed to every stage.

required
TPipelineOutput

The PipelineOutput produced by the last stage and handed to the output processing callback.

required

step(inputs_microbatches, shared_microbatches, callback) abstractmethod

Executes a single pipeline step over one pack of microbatches.

The schedule receives the microbatches. The number of microbatches in the step is len(inputs_microbatches) and may vary between steps.

Parameters:

Name Type Description Default
inputs_microbatches tuple[TPipelineInput, ...]

Per-microbatch PipelineInput (fed to the first pipeline stage).

required
shared_microbatches tuple[TSharedInput, ...]

Per-microbatch SharedInput (fed to every pipeline stage).

required
callback PipelineLossFn[TPipelineOutput] | PipelineResultFn[TPipelineOutput]

Function to compute loss or process pipeline results.

required

PipelineStageInfo dataclass

Holds information about the current position within the distributed pipeline.

Attributes:

Name Type Description
current_stage int

The 0-based index of the current pipeline stage.

num_stages int

The total number of stages in the pipeline.

is_current_stage_first property

Determines if this is the first stage in the pipeline.

Returns:

Type Description
bool

True if current_stage is 0.

is_current_stage_last property

Determines if this is the last stage in the pipeline.

Returns:

Type Description
bool

True if current_stage is the last index.

StageBoundary

Bases: Enum

Identifies which inter-stage edge of a stage a transfer spec describes.

Attributes:

Name Type Description
incoming

The StageTransfer this stage receives from the previous stage.

outgoing

The StageTransfer this stage sends to the next stage.

TensorSpec dataclass

Describes a tensor by its metadata, without allocating it on any device.

Attributes:

Name Type Description
shape tuple[int, ...]

The tensor shape.

dtype dtype

The tensor data type.

layout layout

The tensor memory layout. Defaults to torch.strided.

distribute_layers_for_pipeline_stage(num_layers, num_virtual_layers_pre, num_virtual_layers_post, stage)

Calculates the layer index range for a specific pipeline stage.

This function distributes a given number of layers across multiple pipeline stages as evenly as possible. It accounts for additional, non-layer computational load on the first and last stages (e.g., embeddings and the LM head) by using the concept of 'virtual layers' to reserve capacity.

Parameters:

Name Type Description Default
num_layers int

The total number of primary model layers to be distributed (e.g., the transformer blocks).

required
num_virtual_layers_pre int

The number of 'virtual' layers representing the computational cost of modules on the first stage, before the main layers (e.g., token and positional embeddings).

required
num_virtual_layers_post int

The number of 'virtual' layers representing the computational cost of modules on the last stage, after the main layers (e.g., the final layer normalization and LM head).

required
stage PipelineStageInfo

An object containing total stages and current stage index.

required

Returns:

Type Description
tuple[int, int]

A tuple (start_index, end_index), representing the slice of layers for the given stage. The start_index is inclusive and the end_index is exclusive.

Raises:

Type Description
ValueError

If the pipeline configuration results in a stage having zero or negative layers assigned (pipeline too long for the model size).

d9d.pipelining.factory

AnyPipelineScheduleConfig = Annotated[PipelineScheduleInferenceConfig | PipelineScheduleGPipeConfig | PipelineScheduleLoopedBFSConfig | PipelineSchedule1F1BConfig | PipelineScheduleZeroBubbleVConfig | PipelineScheduleDualPipeVConfig, Field(discriminator='schedule')] module-attribute

Union of all supported pipeline schedule configuration types.

This type alias uses a Pydantic discriminator on the schedule field to allow polymorphic validation and serialization of specific schedule configs (e.g. Inference, GPipe, 1F1B, ZeroBubble, etc.).

PipelineSchedule1F1BConfig

Bases: BaseModel

Configuration for Interleaved 1F1B and Interleaved Zero Bubble execution.

Supports assigning multiple stages per rank and sharding backward to dI and dW to reduce pipeline bubbles.

PipelineScheduleDualPipeVConfig

Bases: BaseModel

Configuration for DualPipeV execution.

A bidirectional pipeline schedule for high-throughput training, utilizing V-shape topology and reciprocal forward/backward scheduling.

PipelineScheduleGPipeConfig

Bases: BaseModel

Configuration for GPipe execution.

This assumes a single stage per rank and processes all microbatches for the forward pass before switching to the backward pass.

PipelineScheduleInferenceConfig

Bases: BaseModel

Configuration for inference-only pipeline execution.

This schedule runs all forward passes sequentially without any backward passes.

PipelineScheduleLoopedBFSConfig

Bases: BaseModel

Configuration for Looped Breadth-First Search execution.

Similar to GPipe, but supports multiple stages per rank (virtualization). It executes all available work for a specific stage before moving to the next.

PipelineScheduleZeroBubbleVConfig

Bases: BaseModel

Configuration for Zero Bubble V (ZBV) execution.

A specialized V-shape topology schedule that splits backward passes into Input and Weight gradients to maximize overlap. Requires exactly 2 stages per rank.

build_schedule(dist_context, schedule_config, model_provider)

Constructs the pipeline schedule and instantiates model stages.

This function coordinates the creation of the pipeline. If the context is distributed, it builds a parallel schedule (PipelineScheduleExecutor) by calculating topology and creating stages for the current rank. If the context is local, it builds an offline schedule (OfflinePipelineExecutor) for direct execution. The number of microbatches is decided per step, when the schedule receives a pack, so it is not fixed here.

Parameters:

Name Type Description Default
dist_context DistributedContext

The distributed context.

required
schedule_config AnyPipelineScheduleConfig

Configuration object determining the schedule strategy.

required
model_provider Callable[[PipelineStageInfo], Module]

A factory function that accepts stage info and returns an nn.Module for that specific stage.

required

Returns:

Type Description
PipelineScheduleInfo

A tuple containing the schedule info (executor and metadata) and a list

list[Module]

of local PyTorch modules created for this rank.