Zero-Bubble Pipeline Parallelism, Proven
A schedule-first guide to zero-bubble pipeline parallelism, including split-backward dependencies, memory accounting, Gantt traces, and a validator.
A pipeline timeline can look full and still compute the wrong gradient, exceed memory, or hide idle time outside the crop. Zero-bubble pipeline parallelism is credible only when its event graph preserves synchronous training semantics and its measured utilization includes fill, steady state, and drain.
The central scheduling move is to separate input-gradient work from weight-gradient work, because only one sits on the inter-stage backward critical path. The audit names the split backward pass, pipeline schedule Gantt, ZB1P ZB2P comparison, and pipeline activation memory explicitly.
Read zero-bubble pipeline parallelism as a dependency graph
Pipeline parallel training partitions layers across stages and divides a global batch into microbatches. A forward wave must fill later stages; backward work flows in the opposite direction; the pipeline eventually drains. Traditional schedules interleave forward and backward passes to reduce the number of live activations, but stages still wait when the next legal operation is unavailable. Those gaps are bubbles. Their cause is not laziness in a worker loop—it is the dependency structure imposed by the schedule.
Start with an event trace, not a utilization percentage. For every stage and microbatch, record forward start and end, backward input-gradient start and end, weight-gradient start and end, communication, recomputation, and optimizer barriers. Preserve warmup and cooldown in the view. Mark an interval idle only after subtracting communication or other useful work, and state whether the reported bubble ratio covers a complete iteration or a chosen steady-state window.
The Zero Bubble Pipeline Parallelism paper observes that a conventional backward pass contains work with different dependencies. Computing the gradient with respect to a stage's input releases the previous stage; computing weight gradients does not. Splitting those components exposes schedulable work that can occupy a gap without delaying the critical backward wave. This is a scheduling opportunity, not permission to violate the later optimizer dependency. The complete weight gradient still has to exist before the synchronous update that consumes it. Zero-bubble pipeline parallelism preserves that dependency.
| Stage | t0 | t1 | t2 | t3 | t4 | t5 | t6 |
|---|---|---|---|---|---|---|---|
| 0 | F0 | F1 | B0 | W0 | B1 | W1 | drain |
| 1 | fill | F0 | B0 | F1 | W0 | B1 | W1 |
Draw the event graph before the Gantt
A Gantt chart says when work ran; an event graph says whether that order was legal. Create nodes for each F(stage, microbatch), B(stage, microbatch), and W(stage, microbatch). Forward on stage s + 1 depends on forward output from stage s. Input-gradient backward on stage s depends on downstream backward from s + 1 and its saved or recomputed forward context. Weight-gradient work depends on local forward and output-gradient data. The optimizer depends on all required W nodes for the step.
Add resource edges so one stage cannot execute two exclusive kernels simultaneously. Add communication events when transfers are not fully hidden. If activation recomputation is enabled, represent it explicitly rather than pretending its time is part of B. Then topologically validate the event list. A visually dense chart can otherwise start a consumer before its producer, overlap kernels that share a stream, or run the optimizer while a delayed W is unfinished.
Use stable identifiers in traces and tests: iteration, stage, virtual stage, microbatch, operation kind, and sequence number. These identifiers let a validator compare a generated plan with runtime events and isolate the first dependency violation. They also prevent a familiar reporting error in which work from adjacent iterations fills the chart but the denominator covers only one. Tensor parallelism for LLM inference adds collectives inside a stage; their dependencies and contention must enter the same graph when zero-bubble pipeline parallelism and tensor parallelism are composed.
- forward F
- input grad B
- weight grad W
- optimizer
Split backward without changing the update
The split is often summarized as B for input gradients and W for weight gradients. The useful invariant is more precise: delaying W may change execution order, but the accumulated parameter gradient presented to the optimizer must match the baseline synchronous schedule within the declared numerical tolerance. That includes microbatch scaling, gradient accumulation, loss scaling, clipping, distributed reduction, tied weights, shared embeddings, and optimizer post-validation behavior.
Build a tiny deterministic model that can run both schedules from identical weights, inputs, seeds, and precision. Compare per-parameter gradients before the optimizer step, optimizer state after the step, and updated weights. Start in a high-precision CPU or deterministic GPU mode, then establish tolerances for production kernels. Run more than one iteration because a misplaced W or barrier may only affect optimizer state on the next step. Include gradient overflow and skipped-step controls when mixed precision is used.
Do not infer correctness from an equal loss curve after a few batches. Errors can cancel in aggregate or remain below that coarse signal. Store checksums by stage and operation, then inspect the first divergent parameter. Tied parameters deserve a separate test because their gradients may arrive from multiple pipeline locations. If the runtime overlaps reduction with W, the reducer must not consume an incomplete bucket. Zero-bubble pipeline parallelism preserves synchronous semantics only when every reordering is contained inside the same update boundary.
Budget activation lifetime, not only tensor size
Scheduling freedom changes how long forward activations and backward intermediates remain live. For each stage and microbatch, mark allocation, last consumer, release, recomputation, and offload intervals. Sum live bytes over event time to obtain a peak, then add model states, communication buffers, kernel workspace, allocator reserve, and graph-capture pools. A schedule that eliminates idle compute by keeping more microbatches in flight may be infeasible even when its arithmetic is correct.
The official zero-bubble repository includes zero-bubble variants and memory-controllable schedules. Its documentation makes the throughput-memory trade-off explicit, including schedules with different activation footprints. Use those implementations as reproducible candidates and record the exact flags, virtual-stage assignment, layer padding, recomputation mode, and repository revision. A schedule family name without its memory setup is not enough to reproduce the run.
Sweep microbatch count and activation policy together. More microbatches can amortize fill and drain yet increase live state or scheduling complexity. Recompute reduces stored activations but consumes stage time that might otherwise host W. Offload creates transfer dependencies and contention. Context parallelism for long context changes per-stage activation shapes and communication. Publish a Pareto frontier of throughput versus peak committed memory, and reject zero-bubble pipeline parallelism candidates that fit only by relying on allocator behavior absent from the production stack.
Validate the schedule as data
Represent a proposed schedule as a list of bounded events rather than hard-coded sleeps in a worker loop. The validator should reject duplicate task IDs, unknown stages, invalid time intervals, missing dependencies, consumers that begin too early, and overlapping exclusive work on one stage. It should calculate makespan, busy time, bubble ratio under a declared denominator, and unfinished activation markers. Run the validator on the planner output before allocating a large training job.
Then compare planned and observed traces. Runtime skew, collective contention, compilation, data stalls, or an imbalanced layer partition can open bubbles that the plan does not contain. Join by event ID, compute start and duration error, and distinguish dependency wait from resource wait. A theoretical schedule is the control; the trace is the result. Keep both when tuning so an optimization that merely shifts idle time into communication remains visible.
The artifact below is intentionally a two-stage model small enough to audit line by line. It covers fill and drain, split backward, completion of activation lifetimes, dependency failures, stage collision, invalid stage assignment, duplicate work, and repeatability. Its bubble threshold is illustrative, not a claim of literal zero for a two-microbatch schedule. Production qualification for zero-bubble pipeline parallelism should calculate both whole-iteration and steady-state ratios and explain which one appears in any headline.
Runnable artifact: The zero-bubble pipeline parallelism schedule checker provides eight assertions over legality, utilization, activation completion, malformed events, and deterministic inspection. Extend its event schema with communication and memory bytes before using it as a planner gate.
Save this as zero-bubble-schedule-check.mjs and run node zero-bubble-schedule-check.mjs. Expected final line: PASS: 8 pipeline schedule assertions.
import assert from "node:assert/strict";
export function inspectSchedule(tasks, stageCount) {
if (!Number.isInteger(stageCount) || stageCount < 2) throw new RangeError("invalid_stage_count");
const byId = new Map(tasks.map((task) => [task.id, task]));
if (byId.size !== tasks.length) throw new Error("duplicate_task");
for (const task of tasks) {
if (!Number.isInteger(task.stage) || task.stage < 0 || task.stage >= stageCount) throw new Error("invalid_stage");
if (!(task.start >= 0 && task.end > task.start)) throw new Error("invalid_interval");
for (const dependencyId of task.dependsOn ?? []) {
const dependency = byId.get(dependencyId);
if (!dependency) throw new Error("missing_dependency");
if (dependency.end > task.start) throw new Error("dependency_violation");
}
}
for (let stage = 0; stage < stageCount; stage += 1) {
const ordered = tasks.filter((task) => task.stage === stage).sort((a, b) => a.start - b.start);
for (let index = 1; index < ordered.length; index += 1) {
if (ordered[index - 1].end > ordered[index].start) throw new Error("stage_overlap");
}
}
const span = Math.max(...tasks.map((task) => task.end));
const busy = tasks.reduce((total, task) => total + task.end - task.start, 0);
const bubbleRatio = 1 - busy / (stageCount * span);
const live = new Map();
for (const task of tasks) {
if (task.kind === "F") live.set(task.micro, (live.get(task.micro) ?? 0) + 1);
if (task.kind === "B") live.set(task.micro, Math.max(0, (live.get(task.micro) ?? 0) - 1));
}
return Object.freeze({ bubbleRatio, span, unfinishedActivations: [...live.values()].reduce((a, b) => a + b, 0) });
}
const schedule = Object.freeze([
{ id: "s0f0", stage: 0, micro: 0, kind: "F", start: 0, end: 1 },
{ id: "s0f1", stage: 0, micro: 1, kind: "F", start: 1, end: 2 },
{ id: "s1f0", stage: 1, micro: 0, kind: "F", start: 1, end: 2, dependsOn: ["s0f0"] },
{ id: "s1b0", stage: 1, micro: 0, kind: "B", start: 2, end: 3, dependsOn: ["s1f0"] },
{ id: "s0b0", stage: 0, micro: 0, kind: "B", start: 3, end: 4, dependsOn: ["s1b0"] },
{ id: "s1f1", stage: 1, micro: 1, kind: "F", start: 3, end: 4, dependsOn: ["s0f1"] },
{ id: "s0w0", stage: 0, micro: 0, kind: "W", start: 4, end: 5, dependsOn: ["s0b0"] },
{ id: "s1w0", stage: 1, micro: 0, kind: "W", start: 4, end: 5, dependsOn: ["s1b0"] },
{ id: "s1b1", stage: 1, micro: 1, kind: "B", start: 5, end: 6, dependsOn: ["s1f1"] },
{ id: "s0b1", stage: 0, micro: 1, kind: "B", start: 6, end: 7, dependsOn: ["s1b1"] },
{ id: "s1w1", stage: 1, micro: 1, kind: "W", start: 6, end: 7, dependsOn: ["s1b1"] },
{ id: "s0w1", stage: 0, micro: 1, kind: "W", start: 7, end: 8, dependsOn: ["s0b1"] },
]);
let assertions = 0;
const check = (fn) => { fn(); assertions += 1; };
check(() => assert.equal(inspectSchedule(schedule, 2).span, 8));
check(() => assert.equal(inspectSchedule(schedule, 2).unfinishedActivations, 0));
check(() => assert.ok(inspectSchedule(schedule, 2).bubbleRatio >= 0 && inspectSchedule(schedule, 2).bubbleRatio < 0.3));
check(() => assert.throws(() => inspectSchedule([...schedule, { id: "s0f0", stage: 0, micro: 9, kind: "F", start: 8, end: 9 }], 2), /duplicate_task/));
check(() => assert.throws(() => inspectSchedule([...schedule, { id: "bad", stage: 2, micro: 0, kind: "W", start: 8, end: 9 }], 2), /invalid_stage/));
check(() => assert.throws(() => inspectSchedule([...schedule, { id: "bad", stage: 0, micro: 2, kind: "F", start: 0.5, end: 1.5 }], 2), /stage_overlap/));
check(() => assert.throws(() => inspectSchedule([...schedule, { id: "bad", stage: 1, micro: 2, kind: "B", start: 0, end: 1, dependsOn: ["s0f1"] }], 2), /dependency_violation/));
check(() => assert.deepEqual(inspectSchedule(schedule, 2), inspectSchedule([...schedule], 2)));
assert.equal(assertions, 8);
console.log("PASS: 8 pipeline schedule assertions");
Reconcile the scheduler with Megatron runtime
The NVIDIA Megatron-LM repository is a practical baseline for pipeline, tensor, context, and data-parallel training behavior. A zero-bubble fork or runtime extension inherits assumptions about microbatch calculators, virtual pipeline stages, gradient synchronization, embedding ties, distributed optimizers, and checkpointing. Map every patch or flag to those interfaces and pin compatible revisions. “Based on Megatron” is not a compatibility contract.
Run baseline and candidate under the same container, topology, dataset shard, model partition, precision, microbatch count, global batch, recomputation, and communication settings. Capture per-stage traces, memory peaks, gradient checksums, samples per second, and loss. Repeat after checkpoint save and resume; schedule state such as consumed samples, virtual-stage position, or optimizer validation must not make the restored iteration differ. Include node failure or process restart if the training platform promises recovery.
At scale, inspect topology placement. A stage boundary across a slower link can dominate a schedule that looked balanced on one node. Concurrent tensor-parallel collectives may contend with pipeline sends. Data loading can starve stage zero and make downstream bubbles inevitable. Disaggregated LLM inference is not a training recipe, but its method of pricing boundaries separately is useful: measure compute, communication, queueing, and transfer rather than hiding them inside zero-bubble pipeline parallelism throughput.
| Invariant | Evidence | Failure signal |
|---|---|---|
| Dependencies | Topological event trace | Consumer starts early |
| Memory | Live activations by stage | Peak exceeds budget |
| Numerics | Gradient checksum | Baseline divergence |
| Throughput | Steady-state bubble ratio | Idle gap moves elsewhere |
Release on a four-part proof
First, dependency proof: the planned graph is acyclic, every event begins after its producers, exclusive stage work does not collide, and the optimizer waits for complete gradients. Second, numerical proof: gradients, optimizer state, and weights match the synchronous baseline within published tolerances across several steps, overflow paths, tied parameters, and resume. Third, memory proof: measured peak committed bytes stay inside a headroom budget on every stage. Fourth, performance proof: complete-iteration and steady-state traces improve throughput without moving the bottleneck into unreported communication or input stalls.
Canary on a shorter representative model before the expensive target run. Watch stage utilization, event-duration variance, activation peaks, collective time, gradient checksum mismatch, loss divergence, skipped steps, and checkpoint recovery. Keep an immediate configuration rollback to the known baseline scheduler. Avoid a fallback that silently changes global batch or accumulation semantics just to finish the job.
Zero-bubble pipeline parallelism is ready when the timeline, dependency graph, memory ledger, and numerical control tell the same story. The schedule does not need every pixel occupied during unavoidable fill and drain; it needs minimal idle time where legal work exists and exact honesty about the remainder. That standard is stronger than a beautiful Gantt and useful at any scale: every reordered operation is justified, every live tensor is budgeted, and every claimed throughput gain preserves the training step the baseline would have computed.