ML Interview Notes
27 min read11 sections
Part 5 · Parallelism and distributed inference · 05-02

Pipeline parallelism and bubbles

Status
SOURCE PINNED
Primary sources
  • vllm/distributed/
  • python/sglang/srt/managers/scheduler_pp_mixin.py
Edition pins
vllm a556f3f · sglang 7d89325

Tensor parallelism runs out of road twice: once when the KV heads run out (§5.1), and once when you leave the node and the all-reduces have to cross a 400 Gb/s link instead of NVLink. Pipeline parallelism is the answer to both, and it buys you exactly one thing — capacity. It will not make a single token come back faster, and the arithmetic below says so unambiguously.

§1

The problem

Llama-3-70B in bf16 is 131 GiB of weights. One H100 SXM has 80 GB. Eight of them, on one node, with NVLink between them, hold it comfortably at TP=8: 16.4 GiB of weights per GPU, ~52 GiB left over for KV. That configuration works and it is what most people run.

Double the context or the concurrency and you need sixteen GPUs. Assume an eight-GPU HGX H100 node with one local NVSwitch domain, so sixteen GPUs span two such nodes. You have two nodes and 400 Gb/s InfiniBand NDR between them (50 GB/s per direction, NVIDIA Quantum-2 NDR, cited). Two things go wrong at once if you answer "TP=16".

The KV heads run out. Llama-3-70B has $h_{kv}=8$. Past TP=8 the KV heads are replicated, not split — §5.1 owns this. Each of the sixteen GPUs still stores a full KV head's worth of every layer. You bought eight more GPUs and the per-token KV footprint per GPU did not move.

The all-reduces cross the wire. A Llama block does two all-reduces — after o_proj, after down_proj — so $L=80$ means 160 collectives per forward pass on $[T, 8192]$ bf16 tensors. Ring all-reduce moves $2\frac{N-1}{N}M$ bytes out of every rank: at $T=64$ and $N=8$ that is 1.84 MB per collective and 294 MB per rank per decode step. Over NVLink at 450 GB/s per direction (H100 datasheet: 900 GB/s bidirectional, cited) that is 0.65 ms against a 5.2 ms step. Over 50 GB/s InfiniBand it is 5.9 ms — more than the compute, before counting 320 synchronisation latencies. Derived, not measured.

And if you try PP on a model whose vLLM implementation was never wired for it, you get this instead of a server:

vllm/config/model.py:L1423-L1430 vLLM
        pipeline_parallel_size = parallel_config.pipeline_parallel_size
        if pipeline_parallel_size > 1 and not self.registry.is_pp_supported_model(
            self.architectures, self
        ):
            raise NotImplementedError(
                "Pipeline parallelism is not supported for this model. "
                "Supported models implement the `SupportsPP` interface."
            )
§2

Mental model

Tensor parallelism cuts every weight matrix the short way and makes eight GPUs cooperate on one layer. Pipeline parallelism cuts the stack: GPU 0 owns layers 0–19, GPU 1 owns 20–39, and so on. No matrix is split, so no collective is needed inside a layer; the only thing that ever leaves a stage is the activation at the seam, a point-to-point send of shape $[T, d]$. That is why the wire cost collapses: TP pays $2L$ collectives per forward pass, PP pays $S-1$ sends. For Llama-3-70B at $L=80$, $S=4$, that is 160 all-reduces versus 3 sends.

Figure 1 — the two cuts, for Llama-3-70B. TP splits inside a block and pays a collective twice per block; PP splits between blocks and pays one point-to-point send per seam. Shapes are per decode step at 64 rows. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit

Loading…
§3

First principles

What actually crosses the seam

Read the model, not the paper. vLLM's Llama returns this when it is not the last stage:

vllm/model_executor/models/llama.py:L408-L434 vLLM
        if get_pp_group().is_first_rank:
            if inputs_embeds is not None:
                hidden_states = inputs_embeds
            else:
                hidden_states = self.embed_input_ids(input_ids)
            residual = None
        else:
            assert intermediate_tensors is not None
            hidden_states = intermediate_tensors["hidden_states"]
            residual = intermediate_tensors["residual"]
# ...
        if not get_pp_group().is_last_rank:
            return IntermediateTensors(
                {"hidden_states": hidden_states, "residual": residual}
            )

Two tensors, not one. The second is the un-added residual, and it exists because both engines fuse the residual add into the next RMSNorm. SGLang's Llama does the identical thing and leaves a note about it:

python/sglang/srt/models/llama.py:L427-L437 SGLang
        if self.pp_group.is_first_rank:
            if input_embeds is None:
                hidden_states = self.embed_tokens(input_ids)
            else:
                hidden_states = input_embeds
            residual = None
        else:
            assert pp_proxy_tensors is not None
            # FIXME(@ying): reduce the number of proxy tensors by not fusing layer norms
            hidden_states = pp_proxy_tensors["hidden_states"]
            residual = pp_proxy_tensors["residual"]

So the seam payload is $2 \cdot T \cdot d \cdot b$ bytes: at $d=8192$, $b=2$, that is 32 KiB per token per seam. Compare against TP=8's $160 \times 2\frac{7}{8} d b = 4.375$ MiB per rank per token. 140×, derived.

Both engines then shard that seam send across the TP group and rebuild it with a local all-gather:

vllm/distributed/parallel_state.py:L1057-L1064 vLLM
        for key, tensor in zip(tensor_keys, tensor_list):
            if tensor.numel() == 0:
                continue

            if self._should_use_all_gather(
                key, tensor.numel(), all_gather_group, all_gather_tensors
            ):
                tensor = tensor.reshape(all_gather_size, -1)[all_gather_rank]

With all_gather_group=get_tp_group() (vllm/v1/worker/gpu_worker.py:L1134-L1138) each of the eight TP ranks pushes 1/8 of the seam tensor over its own NIC and the receiving node all-gathers over NVLink. The cross-node bytes per rank per token drop to 4 KiB. SGLang passes attn_tp_group for the same reason (python/sglang/srt/managers/scheduler_pp_mixin.py:L1044-L1052).

Wire bytes per decode token, Llama-3-70B ($L=80$, $d=8192$, bf16). Derived from ring-all-reduce volume $2\frac{N-1}{N}M$ and the two-tensor seam above; bandwidths cited from vendor specs.
ConfigurationCollectives / sends per tokenBytes out per rank per tokenTime at 50 GB/s
TP=8, all-reduce1604.375 MiB91.8 µs
PP=4, seam send1 (per stage)32 KiB0.66 µs
PP=4 × TP=8, sharded seam1 (per stage)4 KiB0.08 µs

The bubble

Define $S$ = number of stages, $M$ = number of micro-batches, $t$ = time one stage takes on one micro-batch (assume balanced stages). In the naive GPipe schedule, micro-batch $j$ enters stage $s$ at time $(s + j)t$, so the last micro-batch leaves the last stage at $(S - 1 + M)t$. Makespan:

$$ T_{\text{total}} = (M + S - 1)\, t $$

Total device-time is $S \cdot T_{\text{total}}$; useful device-time is $S \cdot M \cdot t$. The idle fraction — the bubble — is

$$ \beta = 1 - \frac{S M t}{S (M + S - 1) t} = \frac{S - 1}{S + M - 1} $$

At $S=4$: $M=1$ gives $\beta = 75\%$, $M=4$ gives $42.9\%$, $M=16$ gives $15.8\%$, $M=64$ gives $4.5\%$. The training remedy is simply "make $M$ big".

Figure 2 — the naive schedule at $S=4$, $M=4$. Each cell is one mean stage-slot of $t = 10.34$ ms (Llama-3-70B: 138.6 GB of decode weight traffic — 131 GiB minus the embedding table — over 4 stages at 3.35 TB/s; derived). 28 cells of device-time, 16 busy, 12 idle: $\beta = 3/7 = 42.9\%$.

slot 0 1 2 3 4 5 6 stage 0 mb0 mb1 mb2 mb3 idle idle idle stage 1 idle mb0 mb1 mb2 mb3 idle idle stage 2 idle idle mb0 mb1 mb2 mb3 idle stage 3 idle idle idle mb0 mb1 mb2 mb3 fill: (S-1) = 3 slots drain: (S-1) = 3 slots makespan = (M + S - 1) t = 7 x 10.34 ms = 72.4 ms bubble = (S-1)/(S+M-1) = 3/7 = 42.9%

Why the training remedy does not transfer to decode

In training, $t$ scales down with $M$ because the forward pass is compute-bound: half the tokens, half the FLOPs, half the time. The bubble shrinks and the makespan barely moves.

Decode is not compute-bound. A decode step is below the ridge point — ~98% of the traffic is reading weights, and weight traffic is independent of batch size. So halving a decode micro-batch does not halve $t$; it leaves $t$ essentially unchanged. Feed the formula that fact:

The inversion

Split one decode batch of 64 sequences into $M=4$ micro-batches of 16 at $S=4$. Each stage still costs $t \approx 10.34$ ms because it still reads all 34.6 GB of its weights. Makespan goes from $4t$ to $(4+4-1)t = 7t$ and throughput falls by 43%. GPipe's remedy makes inference strictly worse. Derived.

Nor is there a smaller unit to split: in decode each sequence contributes exactly one token per step, and you cannot subdivide a token. What both engines do instead is batch-level pipelining: keep $S$ independent batches in flight, drawn from the continuous-batching queue (§1.3), and let each stage work on a different one. There is no fill/drain per step because the stream never ends. The bubble does not leave the formula — it moves into a load condition: if fewer than $S$ batches exist, stages idle.

Figure 3 — the decode case at $S=4$. Independent batches B0, B1, … enter stage 0 every slot. Only the first $S-1$ slots are a bubble; after that every stage is busy every slot. Slot $= t = 10.34$ ms. Token latency is $4t = 41.4$ ms whether or not the pipeline is full. Derived.

slot 0 1 2 3 4 5 6 stage 0 B0 B1 B2 B3 B4 B5 B6 stage 1 B0 B1 B2 B3 B4 B5 stage 2 B0 B1 B2 B3 B4 stage 3 B0 B1 B2 B3 fill bubble: S-1 = 3 slots, once steady state: every stage busy, bubble = 0 B0 enters at slot 0, leaves at slot 3: latency = S t = 41.4 ms one batch retires per slot: throughput = 1 / t = 96.7 batches/s at load < S in-flight batches the diagonal never fills and the bubble returns

Capacity, not latency — the blunt version

Let $W$ be the weight bytes read per forward pass and $B_{\text{HBM}}$ the per-GPU bandwidth. With balanced stages, $t = W / (S \cdot B_{\text{HBM}})$, so

$$ T_{\text{token}} = S\,t = \frac{W}{B_{\text{HBM}}}, \qquad \text{throughput} = \frac{1}{t} = \frac{S\,B_{\text{HBM}}}{W} $$

$S$ cancels out of the latency. In this fixed-TP, weight-bandwidth-only model, adding pipeline stages does not reduce per-token latency. A token still traverses all 80 layers, and the total weight bytes it forces off HBM is the same $W$ whether those bytes sit on one GPU or sixteen. What $S$ buys is throughput, linearly, and the ability to host $S\times$ the parameters.

41.4 ms
70B decode-step floor, PP=1, PP=4 or PP=8 — identical (derived)
throughput at PP=4, saturated
140×
less wire traffic per token than TP=8

TP, by contrast, does cut latency: at TP=8 each GPU reads $W/8$ concurrently, giving $T_{\text{token}} = 5.17$ ms plus collective time. That is the entire trade. TP buys latency at the cost of a link fast enough to carry 160 collectives; PP buys capacity and throughput at no link cost and no latency benefit.

§4

KV per stage, and when to pick PP

Each stage allocates KV only for its own layers. Both engines resolve a [start_layer, end_layer) range at load time and size the pool from it. SGLang makes it explicit:

python/sglang/srt/model_executor/model_runner_components/layer_setup.py:L132-L151 SGLang
    pp_range = _resolve_pp_layer_range(model=model, model_num_layers=model_num_layers)
    num_effective_layers = pp_range.end_layer - pp_range.start_layer
# ...
    return ModelLayerInfo(
        start_layer=pp_range.start_layer,
        end_layer=pp_range.end_layer,
        num_effective_layers=num_effective_layers,
    )

and num_effective_layers is what the KV pool is sized with (python/sglang/srt/model_executor/pool_configurator.py:L150-L160, python/sglang/srt/mem_cache/kv_cache_configurator.py:L1123). vLLM gets there structurally: get_kv_cache_spec walks the attention modules actually present in the static forward context, and on a PP rank the absent ones are PPMissingLayer — torch.nn.Identity subclasses with no attention inside (vllm/v1/worker/gpu_model_runner.py:L7896-L7909).

For Llama-3-70B, per token per layer the KV is $2 \cdot h_{kv} \cdot d_h \cdot b = 2 \cdot 8 \cdot 128 \cdot 2 = 4$ KiB, so 320 KiB for the whole 80-layer stack. That divides by $S$. Here is why it matters, on 16 H100s at --gpu-memory-utilization 0.9 — an H100 80GB reports 79.65 GiB (§2.1), so 71.69 GiB usable, of which 3 GiB is reserved for activations and graphs:

Llama-3-70B KV capacity, 16 × H100 80GB. Derived from $W = 131$ GiB, $h_{kv}=8$, $d_h=128$, bf16. KV-head replication past TP=8 is the mechanism from §5.1.
LayoutGPUsWeights / GPUKV / token / GPUKV pool tokensSeqs at 8k
TP=8 (baseline, 1 node)816.38 GiB40 KiB1.37 M167
TP=16168.19 GiB40 KiB1.59 M193
TP=8 × PP=2168.19 GiB20 KiB3.17 M387

Doubling TP from 8 to 16 buys 16% more concurrency, because past $h_{kv}=8$ every extra rank stores a replicated KV head and only the weight shard shrinks. Adding a pipeline stage instead buys 132%, on the same sixteen GPUs, over the slow link. This is the most under-appreciated property of PP in serving.

Figure 4 — layer-to-stage map, Llama-3-70B at PP=4. Grey dashed entries are PPMissingLayer stubs: allocated as torch.nn.Identity, zero parameters, zero KV. Weight bytes per block are 1.711 GB (7 matrices at $d=8192$, $d_{ff}=28672$, bf16); the per-stage totals below are taken from the 131 GiB checkpoint size instead, which works out to 1.706 GB per block — a 0.3% difference that moves no conclusion. Derived.

get_pp_indices(80, rank, 4) partitions [20, 20, 20, 20] PP rank 0 embed_tokens 2.10 GB layers[0..19] real layers[20..79] stub norm PPMissingLayer lm_head PPMissingLayer stored 36.2 GB read/step 34.1 GB slot 10.19 ms KV/token 80 KiB (20 layers x 4 KiB) out: 2 x [T, 8192] PP rank 1 embed_tokens stub layers[20..39] real layers[0..19] stub layers[40..79] stub norm, lm_head stub stored 34.1 GB read/step 34.1 GB slot 10.19 ms KV/token 80 KiB (20 layers x 4 KiB) in/out: 2 x [T, 8192] PP rank 2 embed_tokens stub layers[40..59] real layers[0..39] stub layers[60..79] stub norm, lm_head stub stored 34.1 GB read/step 34.1 GB slot 10.19 ms KV/token 80 KiB (20 layers x 4 KiB) in/out: 2 x [T, 8192] PP rank 3 embed_tokens stub layers[60..79] real layers[0..59] stub norm RMSNorm lm_head 2.10 GB stored 36.2 GB read/step 36.2 GB slot 10.81 ms KV/token 80 KiB (20 layers x 4 KiB) out: next_token_ids full-stack KV = 320 KiB/token; per stage = 80 KiB. Slowest stage sets the slot: 10.81 ms. This is why get_pp_indices puts leftover layers on the middle partitions, never the last.
Rule 1

Crossing a node boundary

If any parallel dimension must span nodes and you do not have NVLink between them, that dimension is PP. TP stays inside the node. This is not a preference; it is the 140× volume ratio.

Rule 2

Past $h_{kv}$

Once TP would exceed the model's KV-head count, extra TP ranks stop dividing KV. Spend the next factor on PP instead.

Rule 3

Memory-bound, not latency-bound

If your SLO is throughput or concurrency and TPOT has headroom, PP is free capacity. If TPOT is the binding constraint, PP gives you nothing — and $S-1$ extra hops of latency.

§5

How production systems do it

vLLM: layer stubbing plus a future queue in the engine core

Layer assignment is one function. Note the docstring's reasoning about where the remainder goes — it is the balance argument from Figure 4:

vllm/distributed/utils.py:L127-L142 vLLM
def get_pp_indices(
    num_hidden_layers: int, pp_rank: int, pp_size: int
) -> tuple[int, int]:
    """Try to evenly distribute layers across partitions.

    If the number of layers is not divisible by the number of partitions,
    the remaining layers are evenly distributed across all but the last
    partition. The last partition is excluded because it often contains an
    additional norm layer and we are attempting to balance compute.

    If `pp_size > 2` and the number of remaining layers is
    `0 < x <= pp_size - 2` then the remaining layers are evenly distributed
    across the middle partitions. The first and last partitions are excluded
    because they contain the input and output embeddings respectively and we
    are attempting to reduce maximum memory consumption across partitions.
    """

Every model then builds a full-length ModuleList in which the foreign layers are placeholders, so state-dict keys and layer indices stay global:

vllm/model_executor/models/utils.py:L819-L862, L852-L863 vLLM
class PPMissingLayer(torch.nn.Identity):
    """
    A placeholder layer for missing layers in a pipeline parallel model.
    """

    def __init__(self, *args, **kwargs):
        super().__init__()

    def forward(self, *args, **kwargs):
        """Return the first arg from args or the first value from kwargs."""
        return args[0] if args else next(iter(kwargs.values()))
# ...
    start_layer, end_layer = get_pp_indices(
        num_hidden_layers, get_pp_group().rank_in_group, get_pp_group().world_size
    )

    modules = torch.nn.ModuleList(
        [PPMissingLayer() for _ in range(start_layer)]
        + get_offloader().wrap_modules(
            layer_fn(prefix=f"{prefix}.{idx}") for idx in range(start_layer, end_layer)
        )
        + [PPMissingLayer() for _ in range(end_layer, num_hidden_layers)]
    )

The pipelining itself lives in the engine core, not the worker. max_concurrent_batches is the whole scheduler-side contract:

vllm/config/vllm.py:L583-L594 vLLM
    @property
    def max_concurrent_batches(self) -> int:
        # PP requires PP-size concurrent batches to fill the pipeline.
        # Async scheduling requires 2 concurrent batches to overlap.
        pp_size = self.parallel_config.pipeline_parallel_size
        if self.scheduler_config.async_scheduling:
            if self.use_v2_model_runner:
                return pp_size + 1
            # V1 Model Runner does not fully support async scheduling with PP.
            if pp_size <= 1:
                return 2
        return pp_size

That number sizes a deque of in-flight futures, and step_with_batch_queue prefers filling the pipeline over collecting results:

vllm/v1/engine/core.py:L205-L215 vLLM
        # Setup batch queue for pipeline parallelism.
        # Batch queue for scheduled batches. This enables us to asynchronously
        # schedule and execute batches, and is required by pipeline parallelism
        # to eliminate pipeline bubbles.
        self.batch_queue_size = vllm_config.max_concurrent_batches
        self.batch_queue: (
            deque[tuple[Future[ModelRunnerOutput], SchedulerOutput, Future[Any]]] | None
        ) = None
        if self.batch_queue_size > 1:
            logger.debug("Batch queue is enabled with size %d", self.batch_queue_size)
            self.batch_queue = deque(maxlen=self.batch_queue_size)

SGLang: a scheduler process per stage, running a ring

SGLang does not centralise. Every PP stage runs its own Scheduler with its own event loop, and requests, activations and sampled tokens all travel around the ring stage-to-stage. The docstring of event_loop_pp is the clearest single description of inference-time pipelining in either repository:

python/sglang/srt/managers/scheduler_pp_mixin.py:L69-L92 SGLang
    def event_loop_pp(self: Scheduler):
        """
        A scheduler loop for pipeline parallelism.
        Notes:
        1. Each stage runs in the same order and is notified by the previous stage.
        2. We use async send but sync recv to avoid desynchronization while minimizing the communication overhead.
        3. We can use async batch depth to buffer the outputs in the last stage for to allow overlapping the GPU computation and CPU processing and avoid last PP rank staggler.

        Unified Schedule:
        ====================================================================
        Stage P
        recv ith req from previous stage
        recv ith proxy from previous stage
        run ith batch
        recv prev (i+1)% mb_size th outputs
        process batch result of prev (i+1)% mb_size th batch (can be run in parallel with the curr batch GPU computation)
        send ith req to next stage
        send ith proxy to next stage
        send current stage's outputs to next stage(can be stashed and delayed to send later)

        the above order can be optimized and reordered to minimize communication-related CPU stall and overhead bubbles.

        ====================================================================
        """

The in-flight count is explicit, and can exceed $S$:

python/sglang/srt/managers/scheduler_pp_mixin.py:L561-L573 SGLang
    def init_pp_loop_state(self: Scheduler):
        self.pp_loop_size: int = self.ps.pp_size + get_parallel().pp_async_batch_depth
        # In CP mode, attention weights are duplicated, eliminating the need for the attention TP all-gather operation.
        self.require_attn_tp_allgather = (
            not get_parallel().enable_dsa_prefill_context_parallel
        )
        self.mbs = [None] * self.pp_loop_size
        self.last_mbs = [None] * self.pp_loop_size
        self.running_mbs = [
            ScheduleBatch(reqs=[], batch_is_full=False)
            for _ in range(self.pp_loop_size)
        ]
        self.mb_metadata: List[Optional[PPBatchMetadata]] = [None] * self.pp_loop_size

--pp-async-batch-depth (default 0, python/sglang/srt/server_args.py:L1054-L1056) adds slots beyond $S$ so the last rank can buffer sampled tokens and overlap its CPU post-processing with the next GPU batch — the "staggler" in the docstring. It is a bubble that only exists in inference: the last stage does the sampling and detokenisation handoff no other stage does, so its slot is systematically longer.

Where they differ, and why. vLLM keeps one scheduler and treats the pipeline as an asynchronous execution backend: the scheduler never knows which stage a batch is on, only that a future has not resolved. Preemption and prefix-cache bookkeeping stay in one place, at the cost of a scheduler-to-worker round trip per stage. SGLang replicates the scheduler per stage and forwards the request objects themselves down the ring (_pp_send_pyobj_to_next_stage, scheduler_pp_mixin.py:L967-L982): no central coordinator, cheaper per step, but every stage must independently reach the same batching decision — which is why the pipeline demultiplexes message kinds to survive the resulting reordering:

python/sglang/srt/managers/scheduler_pp_mixin.py:L1056-L1065 SGLang
    def _pp_recv_typed_dict(
        self: Scheduler,
        expected_kind: str = "default",
        all_gather_group: Optional = None,
    ) -> Dict[str, torch.Tensor]:
        """Receive a typed tensor dict, demultiplexing by msg_type.

        If a message of the wrong kind is received, it's stashed in the queue
        and we continue receiving until we get the expected kind.
        """

The admission consequence

With $S$ batches in flight, a single stage's running batch can only be $1/S$ of the engine's total concurrency, or the pipeline oversubscribes the KV pool. SGLang encodes exactly that:

python/sglang/srt/managers/scheduler.py:L1059-L1065, L3217-L3220 SGLang
        if not get_parallel().pp_max_micro_batch_size:
            get_context().override(
                "scheduler.pp_max_micro_batch_size_default",
                pp_max_micro_batch_size=max(
                    self.max_running_requests // self.ps.pp_size, 1
                ),
            )
# ...
    def get_num_allocatable_reqs(self, running_bs):
        res = get_parallel().pp_max_micro_batch_size - running_bs
        res = min(res, self.req_to_token_pool.available_size())
        return res

Set --max-running-requests 256 at PP=4 and each micro-batch admits at most 64. Forget this and you will conclude PP "made batching worse" when what actually happened is that your per-step batch got divided by $S$ while your total concurrency did not.

§6

Worked trace

One decode step for one batch through vLLM at PP=2, TP=8, in call order.

  1. EngineCore.step_with_batch_queue (vllm/v1/engine/core.py:L624-L680) finds len(batch_queue) < 2, calls self.scheduler.schedule(), then execute_model(scheduler_output, non_block=True) and pushes the future. Because the queue is not yet full it returns None immediately — the scheduler schedules a second batch before collecting the first.
  2. On PP rank 0, Worker.execute_model (vllm/v1/worker/gpu_worker.py:L1098-L1103) sees is_first_rank, skips the receive, and calls self.model_runner.execute_model(...) with intermediate_tensors=None.
  3. GPUModelRunner._preprocess (gpu_model_runner.py:L3733-L3739) keeps intermediate_tensors = None for the first rank. LlamaModel.forward embeds the tokens, runs layers 0–39 via islice(self.layers, self.start_layer, self.end_layer), and because not get_pp_group().is_last_rank returns IntermediateTensors({"hidden_states": ..., "residual": ...}) (llama.py:L430-L433).
  4. Back in the worker, the result is an IntermediateTensors, so the branch at gpu_worker.py:L1127-L1138 fires: isend_tensor_dict with all_gather_group=get_tp_group(). Each of the 8 TP ranks slices its 1/8 of both tensors (parallel_state.py:L1061-L1064) and posts a non-blocking torch.distributed.isend to the matching rank on the next node. execute_model returns None — rank 0 produces no sampler output at all.
  5. On PP rank 1, Worker.execute_model hits not get_pp_group().is_first_rank and calls irecv_tensor_dict, wrapping the handles in an AsyncIntermediateTensors so the receive overlaps with the runner's own input preparation.
  6. GPUModelRunner.sync_and_gather_intermediate_tensors (gpu_model_runner.py:L3472-L3500) copies the arrivals into the persistent buffers and, if sequence parallelism scattered the residual, all-gathers it back over the TP group first.
  7. Layers 40–79 run, self.norm is a real RMSNorm on this rank, lm_head produces logits, the sampler runs, and a real ModelRunnerOutput comes back — which is what the future in step 1 finally resolves to.
§7

Pitfalls and war stories

Ring deadlock

Every stage sending before receiving is safe on CUDA and fatal elsewhere. SGLang's comment is the clearest statement of the failure I have found in either tree (python/sglang/srt/managers/scheduler_pp_mixin.py:L1230-L1241): "if every PP rank sends first, all ranks block waiting for a receiver and the ring deadlocks." The fix is parity-ordered send/recv: send_first = (not is_xpu()) or ((self.ps.pp_rank % 2) == 0). Symptom is a hang with no error, all ranks at 0% SM utilisation.

PP is incompatible with the things that make a single-stage engine fast. SGLang refuses to start:

python/sglang/srt/server_args.py:L9245-L9253 SGLang
        if self.pp_size > 1:
            assert (
                self.disable_overlap_schedule and self.speculative_algorithm is None
            ), "Pipeline parallelism is not compatible with overlap schedule, speculative decoding"
            assert self.min_free_slots_delay is None, (
                "--min-free-slots-delay is not supported with pipeline "
                "parallelism: allocatable slots per microbatch are bounded by "
                "pp-max-micro-batch-size, so the threshold may never be reached"
            )

The overlap scheduler and PP want the same resource — another batch in flight — and fight over it. Speculative decoding (§6.2) is worse: draft-then-verify needs the sampled token back at the start of the model, an extra full lap of the ring per round. Piecewise CUDA graphs are also off (python/sglang/srt/server_args.py:L4640).

Uneven partitions. $L=80$ divides by 2, 4, 5, 8, 10 but not 3, 6, 7. At PP=6 vLLM logs "Hidden layers were unevenly partitioned: [...]. This can be manually overridden using the VLLM_PP_LAYER_PARTITION environment variable" (vllm/distributed/utils.py:L160-L166). The slowest stage sets the slot time, so the [13,13,13,14,14,13] split the code produces runs at the 14-layer stage's pace on every stage — 4.8% of your throughput lost to a rounding decision. If your stages have different KV footprints or a heavy lm_head, tune the partition by hand.

Preemption gets harder. With several batches in flight, a request the scheduler thinks it has finished may still be mid-pipeline:

vllm/v1/core/sched/scheduler.py:L159-L165 vLLM
            # With overlapping batches (async scheduling or PP), a step may
            # still be writing a freed request's KV blocks. A consumer KV
            # Connector can reallocate and fill those blocks via a load that
            # isn't ordered against that write, so defer freeing them.
            multiple_inflight_batches = self.vllm_config.max_concurrent_batches > 1
            if multiple_inflight_batches and kv_transfer_config.is_kv_consumer:
                self.defer_block_free = True

"PP made my TPOT worse." The ideal fixed-TP comparison predicts added boundary latency plus per-stage Python and launch overhead. If your dashboard shows unchanged-to-slightly-worse TPOT and much higher throughput after enabling PP, the system is working correctly.

Download the CPU quantization and parallelism reference checks. Run python quant_parallel_checks.py: byte ledgers, GPTQ correction, scaling, nibble layouts, paired accuracy, TP projection algebra, PP schedules, routing and semantic-order checks. These test mathematical contracts, not CUDA kernels or engine performance.

§8

Hands-on

two nodes, 8 × H100 each shell
# vLLM: TP inside the node, PP across nodes.
vllm serve meta-llama/Meta-Llama-3-70B-Instruct \
  --tensor-parallel-size 8 --pipeline-parallel-size 2 \
  --max-model-len 8192 --gpu-memory-utilization 0.9

# Confirm the layer split from the worker log prefixes (Worker_PP0_TP0 ... Worker_PP1_TP7),
# set by vllm/v1/executor/multiproc_executor.py:L1065-L1084.

# Force a deliberately unbalanced split and watch the slot time follow the slowest stage:
VLLM_PP_LAYER_PARTITION=48,32 vllm serve meta-llama/Meta-Llama-3-70B-Instruct \
  --tensor-parallel-size 8 --pipeline-parallel-size 2

# SGLang: same shape. Note the mandatory --disable-overlap-schedule.
python -m sglang.launch_server --model-path meta-llama/Meta-Llama-3-70B-Instruct \
  --tp-size 8 --pp-size 2 --disable-overlap-schedule \
  --nnodes 2 --node-rank 0 --dist-init-addr $HEAD:5000

# Raise the in-flight count past pp_size to hide the last-rank sampling stall:
  --pp-async-batch-depth 1

# Retune admission: at pp_size=2 each micro-batch defaults to max_running_requests // 2.
  --pp-max-micro-batch-size 128

Launch scope: the multi-node commands above are configuration sketches, not a complete cluster bootstrap. Provision matching environments and model access, run the complementary node-rank command on every node, verify rendezvous and readiness, and stop all workers before trying another configuration. The GPU runs have not been executed in this checkout.

The measurement worth taking: sweep request rate from 1 to saturation at PP=1 and PP=2 on the same total GPU count, plotting TPOT and output-token throughput separately. Changing PP at fixed GPU count also changes TP: there is no general doubling prediction. Separately, hold TP fixed and add a second pipeline stage on additional GPUs. In the ideal weight-bandwidth model, per-request latency is nearly unchanged while a filled pipeline can approach twice the aggregate throughput. At low load the added boundary increases latency; communication, KV traffic and stage imbalance can change both comparisons.

§9

Exercises

  1. Derive the bubble fraction for $S=8$, $M=1$, and explain in one sentence why an inference engine at PP=8 serving a single user sees exactly this number.
  2. Read vllm/distributed/utils.py:L156-L170. For $L=80$, $S=6$, write out the partitions list the code produces. Which rank is the bottleneck, and what fraction of aggregate throughput is lost relative to a perfectly balanced split?
  3. Llama-3-8B ($L=32$, $d=4096$, $h_{kv}=8$, $d_h=128$) at PP=4. Compute (a) KV bytes per token per stage, (b) seam bytes per token, (c) the ratio of seam bytes to the per-stage weight bytes read during one decode step. What does (c) tell you about the link speed you need?
  4. Predict what happens if you launch SGLang with --pp-size 2 and leave the overlap scheduler enabled, then verify against python/sglang/srt/server_args.py:L9245-L9248. Now predict the same for vLLM with --pipeline-parallel-size 2 --async-scheduling and check vllm/config/vllm.py:L588-L594 — why is the answer different?
  5. You have 32 H100s across 4 nodes and want to serve a 405B-parameter model in fp8 (~405 GB of weights) at 128k context. Propose a TP×PP factorisation and justify each factor from the three rules in §5.2.4.
Answers

1. $\beta = (8-1)/(8+1-1) = 7/8 = 87.5\%$. A single user generates one token per step, which is one batch in flight; with $S=8$ stages and one batch, seven stages idle at every instant. This is the load-dependent bubble of Figure 3 with the diagonal never filling.

2. $80 // 6 = 13$, remainder $80 \bmod 6 = 2$. The loop for i in range(2, remaining + 2) increments partitions[-2] and partitions[-3], giving [13, 13, 13, 14, 14, 13]. Ranks 3 and 4 are the bottleneck at 14 layers. Slot time is set by 14 layers instead of the ideal 13.33, so aggregate throughput is $13.33/14 = 95.2\%$ of balanced — 4.8% lost.

3. (a) $2 \cdot 8 \cdot 128 \cdot 2 = 4$ KiB per layer per token; 8 layers per stage $\Rightarrow$ 32 KiB per token per stage (128 KiB for the full stack). (b) $2 \cdot 4096 \cdot 2 = 16$ KiB per token per seam. (c) 8B weights are ~15.0 GB of decode traffic, so a stage reads ~3.75 GB. Ratio $= 16\,\text{KiB} / 3.75\,\text{GB} \approx 4.3 \times 10^{-6}$. Even a 10 GbE link (1.25 GB/s) moves the seam in 13 µs against a 1.12 ms stage — about 1%. PP tolerates almost any link; what it does not tolerate is high per-message latency at small $S \cdot t$.

4. SGLang raises AssertionError: Pipeline parallelism is not compatible with overlap schedule, speculative decoding at argument-validation time — the server never starts. vLLM does not error: max_concurrent_batches returns pp_size + 1 under the V2 model runner (async scheduling and PP compose there), and for the V1 runner the comment says async scheduling with PP is not fully supported, so it falls through to plain pp_size — async scheduling is silently ignored rather than rejected. Different philosophies: SGLang fails loudly, vLLM degrades quietly.

5. TP=8 within each node (NVLink, and 405B has $h_{kv}=8$ so TP=8 is exactly the point where KV-head splitting stops paying — Rule 2), PP=4 across nodes (Rule 1: the only thing crossing the InfiniBand fabric is a $2 \times [T, 16384]$ seam). Weights per GPU $\approx 405/32 = 12.7$ GB, leaving ~50 GiB for KV, and PP=4 divides the per-token KV by 4 — which is what makes 128k context affordable (Rule 3: this deployment is memory-bound, and the three forward stage boundaries (plus token-feedback communication if counted) add latency).

§10

Key takeaways

  • The seam carries two tensors, not one — hidden_states and the unfused residual — so budget $2 T d b$ bytes per boundary, and know that both engines shard that send across the TP group and all-gather it locally, cutting cross-node bytes by another factor of TP.
  • $T_{\text{token}} = W / B_{\text{HBM}}$ with $S$ cancelled out. This cancellation holds for the ideal weight-bandwidth model with fixed TP and added pipeline stages. PP can improve latency when it avoids offload or replaces expensive cross-node TP, but does not promise a latency reduction. It increases model capacity and can increase saturated throughput, while adding $S-1$ forward boundaries.
  • GPipe's micro-batching remedy inverts in decode: stage time is set by weight traffic, which does not shrink when the micro-batch does, so splitting a decode batch into $M$ pieces multiplies makespan by $(M+S-1)/S$ relative to the unsplit batch's $St$ latency. For $M=2,S=4$, this is $5/4$, not $5/2$. The numerator assumes equal weight-bound stage time $t$ for each micro-batch. The engines pipeline independent batches instead, which turns the bubble from a per-step tax into a low-load phenomenon.
  • KV divides by $S$ and keeps dividing past $h_{kv}$, where TP stops. On 16 H100s, TP=16 buys 16% more concurrent sequences over TP=8; TP=8×PP=2 buys 132%.
  • $S$ batches in flight is a scheduling contract, not an implementation detail: vLLM sizes a future deque by max_concurrent_batches, SGLang divides max_running_requests by pp_size to get pp_max_micro_batch_size. Both change what "batch size" means in your logs.
  • PP is mutually exclusive with the overlap scheduler, speculative decoding and piecewise CUDA graphs in SGLang at this SHA. You are trading engine features for capacity, not getting capacity for free.
§11

Further reading

  • Huang et al., GPipe: Efficient Training of Giant Neural Networks using Pipeline Parallelism (2018) — the source of the $(S-1)/(S+M-1)$ bubble.
  • Narayanan et al., Efficient Large-Scale Language Model Training on GPU Clusters Using Megatron-LM (2021) — interleaved 1F1B, which reduces the bubble to $\frac{1}{v}\frac{S-1}{M}$ for $v$ model chunks per device. Nothing in either serving engine implements it, for the reason in §5.2.3.
  • Narayanan et al., PipeDream (2018) — asynchronous 1F1B; the batch-level pipelining the engines use is closer to this than to GPipe.
  • vLLM PR #4412 — the original pipeline-parallel implementation, including the discussion of why the batch queue lives in the engine core.
  • SGLang PR #5724 — the pipeline-parallel scheduler loop that became scheduler_pp_mixin.py.
  • Neighbouring chapters: §5.1 (tensor parallelism and the $h_{kv}$ wall), §5.3 (DP and EP), §5.4 (what the collectives actually cost on real topologies), §5.5 (how the ranks get launched and wired), §2.1 (the KV sizing formula used above).

The Inference Engineering Course · code read at vllm@a556f3fccb and sglang@7d893255c3, pinned 2026-08-21. See SOURCES for the full provenance record.

Explore the library

Reading preferences

Appearance
18 px