Beyond One GPU: Tensor, Pipeline and Expert Parallelism, and Split Prefill and Decode

Why big models need many GPUs, what the wires between them cost, and how tensor, pipeline and expert parallelism and disaggregated serving split the work: a real two-process tensor-parallel run, measured MoE routing, and worked arithmetic.

Llama 3.1 70B stores its weights in 141 GB. A widely used data-centre GPU, the NVIDIA H100, has 80 GB of memory. The model does not fit, and nothing clever inside one GPU changes that.

So we split the model across several GPUs. The moment we do, a new cost appears that the first six parts of this series could ignore: the GPUs have to talk to each other, many times for every single token. How often they talk, how much they send, and how fast the wire between them is, decides almost everything about how a model should be spread out.

This part builds the whole picture from the ground up:

  1. Why one GPU is not enough: the memory arithmetic for an 8B, a 70B, a 405B and a 671B model, and the speed limit on decode.
  2. The wires: how fast GPUs can talk inside one server and between servers, and a simple model of what a message costs.
  3. Tensor parallelism: split every layer across GPUs. We run a real model this way, in two separate processes that only share numbers through a sum, and check it gives the same answer.
  4. Collectives: all-reduce, all-gather and all-to-all, the few ways a group of GPUs exchange data, with the cost formula worked out.
  5. Pipeline parallelism: give each GPU a slice of the layers instead.
  6. Expert parallelism for mixture-of-experts models, with real routing decisions recorded from a 64-expert model.
  7. Disaggregated prefill and decode: run the two phases from Part 1 on different GPUs, and move the KV cache between them.
  8. The real systems that do this today, and the flags you would set.
  9. How to choose a layout, with worked examples.

This part builds on Part 6 (coming soon), which covered serving on one GPU per copy of the model: latency targets, goodput, queueing, capacity planning and routing between copies. Here we look inside a copy that is too big for one GPU.

1. Why one GPU is not enough

Memory: weights plus the KV cache

A GPU must hold two big things while it serves a model:

  • the weights, read on every forward pass (Part 1);
  • the KV cache, the saved keys and values of every token of every conversation in flight (Part 2).

The weight size is the number of parameters times the bytes per parameter. The KV cache size is the formula from Part 2:

M=N bw  +  2 L HKV dh bkv⏟bytes per token  ×  S×CM = N\,b_w \;+\; \underbrace{2\,L\,H_{\mathrm{KV}}\,d_h\,b_{kv}}_{\text{bytes per token}}\;\times\;S\times C

where:

  • NN is the number of parameters and bwb_w the bytes per parameter (2 for BF16, 1 for FP8);
  • LL is the number of layers, HKVH_{\mathrm{KV}} the number of key-value heads and dhd_h the size of each head;
  • the 2 counts keys and values, and bkvb_{kv} is the bytes per stored number (2 for BF16);
  • SS is the number of conversations being served at once and CC the tokens in each one.

Worked example: Llama 3.1 70B. The Llama 3 report gives 80 layers, 8 KV heads and a model width of 8,192 (64 heads of 128). Counting every matrix gives N=70.6N = 70.6 billion, so the weights take 70.6×109×2=14170.6\times10^9\times 2 = 141 GB. One token of KV cache is 2×80×8×128×2=327,6802\times80\times8\times128\times2 = 327{,}680 bytes, 320 KiB. Serve 32 conversations of 8,192 tokens and the cache is 320 KiB×32×8192=80320\ \text{KiB}\times32\times8192 = 80 GiB. Together that is 227 GB. With about 90% of each 80 GB GPU usable (the rest goes to the CUDA runtime, activations and fragmentation), that needs four H100s.

The same arithmetic for the other models:

weightsKV cache: 32 chats x 8,192 tokensLlama 3.1 8BBF16 weightsLlama 3.1 8B: weights 16 GB, KV 34 GB50 GB -> 1 x H100Llama 3.1 70BBF16 weightsLlama 3.1 70B: weights 141 GB, KV 86 GB227 GB -> 4 x H100Llama 3.1 405BBF16 weightsLlama 3.1 405B: weights 812 GB, KV 135 GB947 GB -> 14 x H100DeepSeek-V3FP8 weightsDeepSeek-V3: weights 671 GB, KV 18 GB689 GB -> 10 x H1001 x 80 GB (90% usable)8 x 80 GB: one node
Weights (BF16, or FP8 for DeepSeek-V3) plus the KV cache for 32 conversations of 8,192 tokens each. Arithmetic from the published model shapes; 90% of each 80 GB GPU counted as usable.

Terminal output of memory_math.py: Llama 3.1 8B 8.0B parameters, 16 GB weights, 128 KiB per token, 32 GiB of KV for 32 x 8K, 50 GB total, 1 H100; Llama 3.1 70B 70.6B, 141 GB, 320 KiB, 80 GiB, 227 GB, 4 H100s; Llama 3.1 405B 405.9B, 812 GB, 504 KiB, 126 GiB, 947 GB, 14 H100s; DeepSeek-V3 671B, 671 GB in FP8, 68.6 KiB, 17 GiB, 689 GB, 10 H100s. Decode floor table: 8B 4.8 ms on 1 GPU down to 0.3 ms on 16; 70B 21.1 ms on 2 GPUs, 10.5 on 4, 5.3 on 8; 405B 15.1 ms on 16; DeepSeek-V3 active weights 11.0 ms

Three things stand out.

The 8B model fits on one GPU with room to spare. For it, more GPUs mean more copies of the model, not a split model. That is the world of Part 6.

The 405B model does not even fit on one 8-GPU server. In BF16 its weights alone are 812 GB, more than the 640 GB of eight H100s. Meta hit exactly this wall:

DeepSeek-V3 is bigger but its cache is smaller. It has 671 billion parameters, stored in FP8 (one byte each), so 671 GB. But it uses multi-head latent attention (MLA, mentioned in Part 2): each layer caches one compressed vector of 512 numbers plus a 64-number position key, shared by all heads. That is 61×(512+64)×2=70,27261\times(512+64)\times2 = 70{,}272 bytes per token, 68.6 KiB, against 504 KiB for Llama 3.1 405B. Its problem is the weights, not the cache.

Speed: decode is limited by reading the weights

Even when a model fits, there is a second reason to spread it: speed. Part 1 showed that a decode step for a small batch is memory-bound: the GPU spends its time reading every weight once, not multiplying. That gives a hard floor on the time per token:

tstep  ≥  N bwp BHBMt_{\text{step}} \;\ge\; \frac{N\,b_w}{p\,B_{\text{HBM}}}

where:

  • N bwN\,b_w is the size of the weights in bytes;
  • pp is the number of GPUs the weights are split over, each reading only its own share;
  • BHBMB_{\text{HBM}} is one GPU's memory bandwidth (3.35 TB/s for an H100).

Worked example. Llama 3.1 70B on 2 H100s: 141 GB/(2×3.35 TB/s)=21.1141\ \text{GB} / (2\times3.35\ \text{TB/s}) = 21.1 ms, at best 47 tokens per second for one user. On 8 H100s each GPU reads only 17.6 GB, so the floor drops to 5.3 ms. Spreading the weights does not just make the model fit; it multiplies the memory bandwidth working on each token.

0.3 ms1 ms3 ms10 ms30 ms100 ms1 GPU2 GPUs4 GPUs8 GPUs16 GPUsLlama 3.1 8B on 1 GPUs: 4.8 msLlama 3.1 8B on 2 GPUs: 2.4 msLlama 3.1 8B on 4 GPUs: 1.2 msLlama 3.1 8B on 8 GPUs: 0.6 msLlama 3.1 8B on 16 GPUs: 0.3 msLlama 3.1 8B: 4.8 msLlama 3.1 70B on 2 GPUs: 21.1 msLlama 3.1 70B on 4 GPUs: 10.5 msLlama 3.1 70B on 8 GPUs: 5.3 msLlama 3.1 70B on 16 GPUs: 2.6 msLlama 3.1 70B: 21.1 msLlama 3.1 405B on 16 GPUs: 15.1 msLlama 3.1 405B: 15.1 msDashed: the weights do not fit there. BF16 weights read once per step at 3.35 TB/s per H100, perfect split.
The lowest possible time per decode step at batch 1, if the weights are split perfectly and communication were free. Arithmetic: BF16 weights, 3.35 TB/s per H100.

That last condition, "if communication were free", is the whole subject of this article. It is not free.

2. The wires between GPUs

Inside a server and between servers

A typical AI server (NVIDIA calls it a node) has eight GPUs. They are connected to each other in two very different ways, depending on whether the other GPU is in the same box.

One server (node): 8 GPUsGPU 080 GB HBMGPU 180 GB HBMGPU 280 GB HBMGPU 380 GB HBMGPU 480 GB HBMGPU 580 GB HBMGPU 680 GB HBMGPU 780 GB HBMNVSwitch: any GPU to any GPU900 GB/s per GPU (both directions)8 network cards400 Gb/s each = 50 GB/s(InfiniBand or Ethernet)other nodesthrough network switchesabout 9x slowerthan NVLinkInside: NVLink through NVSwitch. Between nodes: one NIC per GPU, over the network.
One 8-GPU server, as in NVIDIA's DGX H100: NVLink through NVSwitch inside, one 400 Gb/s network card per GPU to the rest of the cluster.

Put the numbers side by side:

10 GB/s100 GB/s1,000 GB/sHBM inside one H100HBM inside one H100: 3350 GB/s3,350NVLink 4 per GPU (both directions)NVLink 4 per GPU (both directions): 900 GB/s900PCIe Gen5 x16 (both directions)PCIe Gen5 x16 (both directions): 128 GB/s128InfiniBand NDR, one 400 Gb/s NICInfiniBand NDR, one 400 Gb/s NIC: 50 GB/s50Ethernet 100 Gb/sEthernet 100 Gb/s: 12.5 GB/s12.5Spec-sheet peaks (NVIDIA H100, ConnectX-7). Log scale: each grid line is 10x.
Peak bandwidth of each link, from NVIDIA's H100 and ConnectX-7 pages. Note the log scale: each step down is several times slower.
LinkPeak bandwidthCompared with NVLink
HBM, inside one H1003,350 GB/s(the GPU's own memory)
NVLink 4, GPU to GPU in a server900 GB/s both ways (450 each way)1x
PCIe Gen5 x16128 GB/s both ways (64 each way)7x slower
InfiniBand NDR, one 400 Gb/s card50 GB/s each way9x slower
Ethernet, 100 Gb/s12.5 GB/s each way36x slower

These are NVIDIA's published peaks (the H100 page lists "NVIDIA NVLink: 900GB/s" and "PCIe Gen5: 128GB/s"; the ConnectX-7 page lists 400 Gb/s). The newer Blackwell generation doubles NVLink to 1,800 GB/s per GPU and can join 72 GPUs into one NVLink domain (the "NVL72" racks), which moves the line between "inside" and "outside" further out. The shape of the problem stays the same.

Real clusters see less than peak. DeepSeek reports what their H800 cluster (a version of the H100 with reduced NVLink bandwidth, made for export to China) delivers in practice:

What one message costs: latency plus size over bandwidth

Every message between GPUs costs a fixed amount of time before any data moves (setting up, synchronising, crossing switches), plus time proportional to its size. This is called the alpha-beta model:

T(n)=α+nβT(n) = \alpha + \frac{n}{\beta}

where:

  • nn is the message size in bytes;
  • α\alpha (alpha) is the fixed cost per message, in seconds;
  • β\beta (beta) is the bandwidth in bytes per second.

Worked example. Send 16 KiB (16,384 bytes) over NVLink at 450 GB/s each way. The size term is 16,384/(450×109)=3616{,}384 / (450\times10^9) = 36 ns. If the fixed cost is a few microseconds (an assumed figure; NVIDIA does not publish one), it is about 100 times bigger than the data term. Small messages are all alpha; large messages are all beta.

NVIDIA does not publish an alpha for NVLink or InfiniBand, and I cannot measure one without the hardware. So I measured the shape on what I have: separate processes on this laptop's CPU, exchanging data with torch.distributed (the same library NVIDIA GPUs use, but with its CPU backend, called gloo).

100 us1,000 us10,000 us100,000 us4 B1 KiB1 MiB64 MiB2 processes, 4 B: 131 us2 processes, 16 B: 191 us2 processes, 64 B: 259 us2 processes, 256 B: 256 us2 processes, 1,024 B: 249 us2 processes, 4,096 B: 214 us2 processes, 16,384 B: 332 us2 processes, 65,536 B: 396 us2 processes, 262,144 B: 529 us2 processes, 1,048,576 B: 761 us2 processes, 4,194,304 B: 2573 us2 processes, 16,777,216 B: 8761 us2 processes, 67,108,864 B: 37098 us4 processes, 4 B: 555 us4 processes, 16 B: 785 us4 processes, 64 B: 1435 us4 processes, 256 B: 1276 us4 processes, 1,024 B: 1297 us4 processes, 4,096 B: 1394 us4 processes, 16,384 B: 1382 us4 processes, 65,536 B: 1304 us4 processes, 262,144 B: 1447 us4 processes, 1,048,576 B: 2249 us4 processes, 4,194,304 B: 5256 us4 processes, 16,777,216 B: 20922 us4 processes, 67,108,864 B: 76281 us2 processes: alpha 249 us, beta 1.8 GB/s4 processes: alpha 1276 us, beta 0.9 GB/sMeasured: torch.distributed all_reduce, gloo backend, CPU processes on this laptop. Flat, then a slope.
Measured on this laptop: the time of one all-reduce (defined in Section 4) between 2 or 4 CPU processes, against message size. The absolute numbers describe this laptop's shared-memory path while it was busy with other jobs, not any GPU link.

Terminal output of tp_demo.py part C: gloo all_reduce timings for 2 and 4 processes from 4 bytes to 64 MiB, with the fitted alpha and beta

The curve is flat for small messages and then rises in step with size: exactly α+n/β\alpha + n/\beta. On this laptop the flat part is about 0.25 ms with 2 processes and 1.3 ms with 4: more participants means more steps and more waiting for the slowest one, a first hint of Section 4. (These alphas are hundreds of times larger than a GPU's, because they are CPU processes on a busy machine; only the shape carries over.) The flat part is why the next sections keep asking two separate questions about every design: how many messages per token, and how many bytes per message.

3. Tensor parallelism: split every layer

The idea

Tensor parallelism attacks both problems from Section 1 at once: each GPU stores 1/p1/p of the weights, and each GPU reads only 1/p1/p of them per step. The price is communication inside every layer. The trick, from the Megatron-LM paper (Shoeybi et al., 2019), is to cut the matrices so that the communication happens as rarely as possible.

Splitting the MLP: columns, then rows

A transformer's MLP block is two matrix multiplications with a nonlinearity between them:

Y=GeLU(XA),Z=YBY = \mathrm{GeLU}(XA), \qquad Z = YB

where:

  • XX is the input, one row per token, dd columns wide (the model width);
  • AA is a d×fd\times f weight matrix that widens each token to ff numbers (the FFN width);
  • GeLU is the nonlinear function applied to every number (Llama uses a close cousin, SwiGLU);
  • BB is an f×df\times d matrix that narrows each token back to dd numbers.

There are two ways to cut AA in half. Megatron's authors explain why only one of them works well:

Written out for two GPUs, with A=[A1,A2]A = [A_1, A_2] split by columns and B=[B1B2]B = \begin{bmatrix} B_1 \\ B_2 \end{bmatrix} split by rows:

Z=GeLU(X[A1,A2])[B1B2]=GeLU(XA1) B1⏟GPU 0+GeLU(XA2) B2⏟GPU 1Z = \mathrm{GeLU}(X[A_1, A_2])\begin{bmatrix} B_1 \\ B_2 \end{bmatrix} = \underbrace{\mathrm{GeLU}(XA_1)\,B_1}_{\text{GPU 0}} + \underbrace{\mathrm{GeLU}(XA_2)\,B_2}_{\text{GPU 1}}

The GeLU is applied to each column of XAXA separately, so it does not care how the columns are grouped. The only cross-GPU step is the final +.

Megatron MLP on 2 GPUs: Y = GeLU(X A), Z = Y BXfull copyA1half the columnsY1 = GeLU(X A1)no talkingB1half the rowsXfull copyA2half the columnsY2 = GeLU(X A2)no talkingB2half the rowsall-reduceZ = Y1 B1 + Y2 B2every GPU gets ZGPU 0 above, GPU 1 below: each holds half of A and half of BThe only communication: one sum of the partial outputs, the size of the activations (tokens x d).
The Megatron MLP split on two GPUs. Each GPU computes a complete partial output with no communication; one all-reduce adds them.

Check it: the sum of the parts equals the whole

This is easy to verify. The first part of tp_demo.py builds an MLP with Qwen2.5-0.5B's sizes (d=896d = 896, f=4,864f = 4{,}864), splits it two, four and eight ways, and compares the sum of the pieces with the unsplit result:

python
full = F.gelu(X @ A) @ B
for p in [2, 4, 8]:
    A_parts = A.chunk(p, dim=1)              # column split: each GPU gets ffn/p output columns
    B_parts = B.chunk(p, dim=0)              # row split: each GPU gets the matching ffn/p rows
    partial = [F.gelu(X @ Ai) @ Bi for Ai, Bi in zip(A_parts, B_parts)]   # no communication needed here
    Y = sum(partial)                         # the all-reduce: add the p partial outputs
    res[p] = float((Y - full).abs().max())

chunk(p, dim=1) cuts AA into pp blocks of columns; chunk(p, dim=0) cuts BB into the matching blocks of rows. Each list entry in partial is what one GPU would compute on its own. sum(partial) is the all-reduce. Then the wrong way, splitting AA by rows and applying GeLU before adding:

python
Xs, As = X.chunk(2, dim=1), A.chunk(2, dim=0)
wrong = sum(F.gelu(Xi @ Ai) for Xi, Ai in zip(Xs, As)) @ B
plain text
PART A: split matrix multiplies (float64, CPU)
p=2: each part A_i (896, 2432), B_i (2432, 896); max |sum of parts - unsplit| = 1.1e-14
p=4: each part A_i (896, 1216), B_i (1216, 896); max |sum of parts - unsplit| = 1.3e-14
p=8: each part A_i (896, 608), B_i (608, 896); max |sum of parts - unsplit| = 1.2e-14
row-split first matrix, GeLU applied before adding: max error 0.909 (output values are about 0.533 on average) -> needs a sync before GeLU

The column-then-row split matches the unsplit result to 10−1410^{-14}, which is just the rounding of 64-bit floats. The row split is wrong by more than the size of the answer itself, because GeLU(a+b)≠GeLU(a)+GeLU(b)\mathrm{GeLU}(a+b) \ne \mathrm{GeLU}(a)+\mathrm{GeLU}(b). To use it you would have to add the halves before the GeLU: a second all-reduce per layer.

Splitting attention: whole heads per GPU

Attention splits even more naturally. Its heads are already independent: head 3 never looks at head 5's numbers until the output projection mixes them. So each GPU takes a whole group of heads, with their query, key and value weights (a column split), and the matching rows of the output projection WOW_O (a row split). Again one all-reduce adds the partial outputs.

Attention on 2 GPUs: whole heads per GPU, and each GPU caches only its own headsGPU 0head 0head 1head 2head 3KV head 0KV cache: halfpartial output = its heads x its rows of W_oGPU 1head 4head 5head 6head 7KV head 1KV cache: halfpartial output = its heads x its rows of W_oall-reduce (sum)Qwen2.5-0.5B in our demo: 14 query heads and 2 KV heads, so TP=2 gives 7 + 1 per GPU.
Attention split by heads. Each GPU computes its own heads, keeps only its own key-value heads in its KV cache, and produces a partial output for the all-reduce.

There is a bonus: each GPU only caches the keys and values of its own heads. The KV cache is split pp ways along with the weights, so the room for conversations grows with every GPU you add.

There is also a limit. With grouped-query attention (Part 2), Llama 3.1 70B has 64 query heads but only 8 KV heads. At TP=8 each GPU gets exactly one KV head. At TP=16 two GPUs would need the same KV head, so it must be copied, and the cache stops shrinking. That is one reason tensor parallelism usually stops at the 8 GPUs of one server.

The paper's own picture shows both blocks. The boxes marked ff and gg are where communication happens; in the forward pass ff does nothing and gg is the all-reduce:

One transformer layer under tensor parallelism, repeated for every layerattention (my heads)all-reduceMLP (my slice)all-reduce... next layerLlama 3.1 70B: 80 layers x 2 = 160 all-reduces for every decode step.Each carries batch x 8,192 x 2 bytes: 16 KiB at batch 1, 1 MiB at batch 64.Every GPU waits at each all-reduce, so its cost adds directly to the time per token.
Two all-reduces per layer, in every layer, for every forward pass. The GPUs cannot start the next block until the sum is complete.

A real tensor-parallel run, in two processes

Splitting one MLP is a toy. To be sure the whole recipe works, tp_demo.py runs all 24 layers of Qwen2.5-0.5B with tensor parallelism across two separate operating-system processes. Each process loads the model, keeps only its own half of every layer, and the two talk only through torch.distributed.all_reduce, the same call vLLM and SGLang make on NVIDIA GPUs (here with the CPU backend, gloo, since there is no NVIDIA GPU). Qwen2.5-0.5B has 14 query heads and 2 KV heads, so with TP=2 each process gets 7 query heads and 1 KV head.

Each process slices its share of the weights once, at load time:

python
qs = slice(rank * self.hq * hd, (rank + 1) * self.hq * hd)     # this rank's query heads
ks = slice(rank * self.hkv * hd, (rank + 1) * self.hkv * hd)   # this rank's KV heads
fs = slice(rank * ffn, (rank + 1) * ffn)                       # this rank's MLP hidden units
self.layers.append(dict(
    wq=g('self_attn.q_proj.weight')[qs], bq=g('self_attn.q_proj.bias')[qs],      # column split
    wk=g('self_attn.k_proj.weight')[ks], bk=g('self_attn.k_proj.bias')[ks],
    wv=g('self_attn.v_proj.weight')[ks], bv=g('self_attn.v_proj.bias')[ks],
    wo=g('self_attn.o_proj.weight')[:, qs],                                        # row split
    wg=g('mlp.gate_proj.weight')[fs], wu=g('mlp.up_proj.weight')[fs],              # column split
    wd=g('mlp.down_proj.weight')[:, fs]))                                          # row split

PyTorch stores a linear layer's weight as (output, input), so a column split of the maths is a slice of the weight's first axis ([qs]), and a row split is a slice of its second axis ([:, qs]). The gate and up projections of Qwen's SwiGLU MLP are both column-split the same way, so their elementwise product stays local.

Then each layer of the forward pass is ordinary code, with exactly two collective calls:

python
a = F.scaled_dot_product_attention(q, K, V, is_causal=ids.shape[0] > 1)   # my 7 heads only
a = a.transpose(0, 1).reshape(ids.shape[0], -1)
h = h + self.all_reduce(a @ L['wo'].T)       # all-reduce 1 of 2 in this layer
x = rms(h, L['ln2'], c.rms_norm_eps)
m = F.silu(x @ L['wg'].T) * (x @ L['wu'].T)  # my half of the MLP
h = h + self.all_reduce(m @ L['wd'].T)       # all-reduce 2 of 2 in this layer

self.all_reduce wraps dist.all_reduce, which replaces each process's tensor with the sum over both processes, and counts the calls and bytes. Everything outside the two calls (embeddings, normalisation, the residual additions, the output head) is simply done on both processes, which is how Megatron handles them too (it can also split the vocabulary, which this demo skips).

The script compares the last-token logits with the unsplit Hugging Face model and greedily generates 24 tokens both ways:

Terminal output of tp_demo.py part B: TP=1 holds 100% of the layer weights and 912 KiB of KV cache, max logit difference 4.9e-05 against Hugging Face, same 24 greedy tokens; TP=2 rank 0 holds 50% of the layer weights and 456 KiB of KV cache, max logit difference 5.0e-05, same 24 tokens; 48 all-reduce calls per token carrying 168 KiB, and the generated continuation

The split model gives the same 24 tokens as the original, and the logits agree to about 5×10−55\times10^{-5}, the same small difference that the unsplit re-implementation has (float32 sums in a different order). Each process holds exactly half of the layer weights and half of the KV cache. And every decode token costs 48 all-reduces: 24 layers times two.

The timings in that run are not a speed-up: both "GPUs" are processes on one CPU, sharing the same cores and memory, so splitting gives each process half the work but no extra hardware to do it with. This demo checks correctness and counts messages; the next pages estimate speed.

How many bytes per token?

Each all-reduce carries one activation vector per token: dd numbers. Per decode step:

calls=2L,bytes per call=b×d×s\text{calls} = 2L, \qquad \text{bytes per call} = b\times d\times s

where:

  • LL is the number of layers (two all-reduces each);
  • bb is the number of tokens in the step (the batch size during decode);
  • dd is the model width and ss the bytes per number (2 for BF16).

Worked example, Qwen2.5-0.5B in the demo: 2×24=482\times24 = 48 calls, each 1×896×4=3,5841\times896\times4 = 3{,}584 bytes in float32, so 48×3,584=172,03248\times3{,}584 = 172{,}032 bytes = 168 KiB per token, exactly the 168.0 KiB the script counted.

Worked example, Llama 3.1 70B in BF16: 2×80=1602\times80 = 160 all-reduces per step. At batch 1 each carries 8,192×2=168{,}192\times2 = 16 KiB; at batch 64, 1 MiB. Compare that with what each GPU reads from its own memory at TP=8: 17.6 GB of weights. The bytes sent are tiny next to the bytes read. But there are 160 separate messages, and each must finish before the layer can go on. From Section 2: when messages are small, it is their count, through α\alpha, that costs time.

What each GPU's share costs: a measurement

To see how the work per GPU shrinks, measure_mps.py times one real Llama 3.1 8B MLP matrix (14,336 by 4,096, BF16, 117 MB) on this laptop's Apple GPU, whole and cut to the half and quarter that TP=2 and TP=4 would give each GPU:

whole matrixTP=2 shardTP=4 shard1 token1 tokens TP=1: 0.879 ms0.879 ms1 tokens TP=2: 0.690 ms0.690 ms 1.27x1 tokens TP=4: 0.728 ms0.728 ms 1.21x16 tokens16 tokens TP=1: 1.388 ms1.388 ms16 tokens TP=2: 0.930 ms0.930 ms 1.49x16 tokens TP=4: 0.737 ms0.737 ms 1.88x128 tokens128 tokens TP=1: 1.546 ms1.546 ms128 tokens TP=2: 1.022 ms1.022 ms 1.51x128 tokens TP=4: 0.819 ms0.819 ms 1.89x1,024 tokens1024 tokens TP=1: 6.441 ms6.441 ms1024 tokens TP=2: 3.825 ms3.825 ms 1.68x1024 tokens TP=4: 2.023 ms2.023 ms 3.18x4,096 tokens4096 tokens TP=1: 32.829 ms32.829 ms4096 tokens TP=2: 16.181 ms16.181 ms 2.03x4096 tokens TP=4: 9.032 ms9.032 ms 3.63xMeasured on the Apple GPU (mps), BF16, 14,336 x 4,096 matrix. Bars scaled per row.
Measured on the Apple GPU (mps): one 8B MLP matrix, whole and as TP=2 and TP=4 shards, for 1 to 4,096 tokens. Fastest of 40 runs.

Terminal output of measure_mps.py: GPU copy bandwidth, then for 1 to 4,096 tokens the time, TFLOP/s, GB/s and speedup of the whole matrix and of TP=2 and TP=4 shards, then the all-reduce bytes each shard would send

Two regimes show up. With 4,096 tokens the work is arithmetic, and the shards behave as hoped: the half takes half the time (2.03x faster) and the quarter close to a quarter (3.63x). With one token, the whole matrix is a 117 MB read at 134 GB/s, 0.88 ms. But the half and quarter shards take 0.69 and 0.73 ms, barely faster. Below a certain size, every GPU operation has a fixed cost of its own (launching the kernel and getting it going), and splitting the work cannot go under that floor. It is the same shape as the alpha of Section 2, inside one GPU. Engines fight it by capturing a whole decode step as one CUDA graph, so that hundreds of small launches cost about one; the principle stays: cutting small work very finely gives diminishing returns, even before any communication.

The last block of the output adds the communication those shards would need: the partial outputs are only 8 KiB per token, which takes nanoseconds on any link. The bytes are not the problem; the number of separate exchanges is. (The copy bandwidth here, 153 GB/s, is lower than the 262 GB/s measured in Part 1 because this run shared the laptop with other jobs; the fastest of 40 runs is reported for every number.)

Putting it together: TP on 70B

Now add the communication back. layout_model.py estimates one Llama 3.1 70B decode step on H100s as the time to read the weights and the KV cache (or to do the arithmetic, whichever is longer) plus 160 all-reduces:

tstep=max⁡ ⁣(W+Kp BHBM, 2Nbp F)+2L(2α+2(p−1)p⋅b d sβ)t_{\text{step}} = \max\!\left(\frac{W + K}{p\,B_{\text{HBM}}},\ \frac{2Nb}{p\,F}\right) + 2L\left(2\alpha + \frac{2(p-1)}{p}\cdot\frac{b\,d\,s}{\beta}\right)

where:

  • WW is the weight bytes and KK the KV-cache bytes read in the step (bb sequences of 4,096 tokens each);
  • FF is one GPU's peak BF16 rate (989 TFLOP/s for an H100) and 2Nb2Nb the arithmetic for bb tokens;
  • the last term is 2L2L all-reduces, each costing two message delays (2α2\alpha) plus its bytes, using the two-step all-reduce explained in Section 4, with β\beta = 450 GB/s;
  • α=5 μ\alpha = 5\ \mus is an assumption (NVIDIA publishes none); it is varied below.
read weights + KV (memory)all-reduces (communication)batch 1, TP=2batch 1 TP=2: 21.26 ms memory/compute, 1.61 ms all-reduce22.9 ms (comm 7%)batch 1, TP=4batch 1 TP=4: 10.63 ms memory/compute, 1.61 ms all-reduce12.2 ms (comm 13%)batch 1, TP=8batch 1 TP=8: 5.32 ms memory/compute, 1.61 ms all-reduce6.9 ms (comm 23%)batch 64, TP=4batch 64 TP=4: 16.94 ms memory/compute, 2.16 ms all-reduce19.1 ms (comm 11%)batch 64, TP=8batch 64 TP=8: 8.47 ms memory/compute, 2.25 ms all-reduce10.7 ms (comm 21%)Roofline model with H100 peaks; all-reduce = 2 x 5 us + bytes / 450 GB/s, 160 per step (alpha assumed).
Modelled Llama 3.1 70B decode step on H100s, split into reading memory and all-reduces. Peak numbers throughout, so real systems are slower.

Terminal output of layout_model.py: tensor parallel scaling for Llama 3.1 70B at batch 1 and 64, the alpha sensitivity, the one-node comparison of TP=8, TP=4 x 2 and TP=2 x 4, the 405B two-node comparison and the pipeline schedule

Worked example at batch 1. TP=2: reading 141 GB at 2×3.352\times3.35 TB/s takes 21.3 ms (with the cache); 160 all-reduces at 2×5 μs2\times5\ \mu\text{s} each add 1.6 ms; total 22.9 ms. TP=8: memory 5.3 ms, all-reduces still 1.6 ms, total 6.9 ms. Going from 2 to 8 GPUs cut the memory time by 4x but left the communication exactly where it was, so the step is only 3.3x faster, and communication grew from 7% to 23% of it. With α=2 μ\alpha = 2\ \mus the TP=8 step is 6.0 ms; with α=10 μ\alpha = 10\ \mus it is 8.5 ms.

This is a general law, and the Google team that scaled PaLM inference put it plainly:

Two practical consequences follow. Tensor parallelism belongs where α\alpha is small and links are fast: inside one NVLink server. And because it needs that, inference engines put real effort into making all-reduce fast: vLLM and TensorRT-LLM ship their own all-reduce kernels for small messages on NVLink, rather than relying only on the general NCCL library. Section 4 shows why that helps.

4. Collectives: how a group of GPUs exchanges data

The four you need

When a group of GPUs exchange data in a fixed pattern, the operation is called a collective. NVIDIA's NCCL library (pronounced "nickel") implements them on GPUs; torch.distributed calls it. Four collectives cover almost everything in this article.

collectives.py runs all four on four simulated GPUs (list entries in NumPy), where rank rr starts with [10r,10r+1,10r+2,10r+3][10r, 10r+1, 10r+2, 10r+3]:

all-reduceall get the full sumr060646872r160646872r260646872r360646872tensor parallel (Megatron)all-gatherall get every piecer00112233r10112233r20112233r30112233data-parallel attentionreduce-scatterrank r: sum of piece rr060r164r268r372half of a ring all-reduceall-to-allpiece r goes to rank rr00102030r11112131r22122232r33132333expert parallel (MoE)Start: rank r holds [10r, 10r+1, 10r+2, 10r+3]. All-gather shares each rank's own piece (piece r).
The four collectives on four ranks, with the numbers from collectives.py. All-to-all keeps piece r on rank r (highlighted) and swaps the rest.

Ring all-reduce, step by step

How do pp GPUs compute a sum without one GPU becoming a bottleneck? The classic answer is the ring. Arrange the GPUs in a circle and cut each vector into pp chunks.

  1. Reduce-scatter, p−1p-1 steps. At each step every GPU sends one chunk to its right-hand neighbour, which adds it to its own copy of that chunk. After p−1p-1 steps, each GPU holds one chunk that contains the complete sum.
  2. All-gather, p−1p-1 more steps. Each GPU passes its finished chunk to the right, and the finished chunks travel round the ring until everyone has all of them.

Here is the trace for four ranks holding [1,2,3,4][1,2,3,4] times 1, 2, 3 and 4:

start1234246836912481216reduce 1123203468310912482116reduce 2122420346286109124182116reduce 3120242034302861094010182116gather 1102024203203028610304010182140gather 21020244010203028620304010183040gather 310203040102030401020304010203040rank 0rank 1rank 2rank 3reduce-scatter: add chunks round the ringall-gather: pass the sums roundHighlighted: a chunk that already holds its final sum. After 2(p-1) = 6 steps every rank has [10, 20, 30, 40].
Ring all-reduce traced by collectives.py. Highlighted cells already hold their final sum. Every rank ends with [10, 20, 30, 40].

Terminal output of collectives.py: the ring all-reduce trace with each rank's four numbers after every reduce-scatter and all-gather step, the check that every rank ends with the plain sum, the bytes each rank sends for p = 2, 4 and 8 against the formula 2(p-1)/p, and the other three collectives

What the ring costs

Each step moves one chunk, n/pn/p bytes, from every GPU at the same time. There are 2(p−1)2(p-1) steps. So, with the alpha-beta model from Section 2:

Tring=2(p−1)(α+np β)=2(p−1) α⏟grows with p+2(p−1)p⋅nβ⏟almost constantT_{\text{ring}} = 2(p-1)\left(\alpha + \frac{n}{p\,\beta}\right) = \underbrace{2(p-1)\,\alpha}_{\text{grows with } p} + \underbrace{\frac{2(p-1)}{p}\cdot\frac{n}{\beta}}_{\text{almost constant}}

where:

  • pp is the number of GPUs and nn the size of the vector being summed, in bytes;
  • α\alpha is the fixed cost of one step and β\beta each GPU's sending bandwidth.

The byte term is the famous property of the ring: each GPU sends 2(p−1)/p2(p-1)/p times the vector, which is never more than 2x the vector however many GPUs there are. The simulation counts the bytes and agrees: 1.000, 1.500 and 1.750 times nn for 2, 4 and 8 ranks. (NCCL's performance notes use the same 2(p−1)/p2(p-1)/p factor to turn measured times into "bus bandwidth".)

The latency term is the catch. It grows with pp: 2(p−1)=142(p-1) = 14 steps on 8 GPUs, and every GPU must wait for its neighbour at every step.

Worked example: Llama 3.1 70B at batch 1, TP=8, NVLink. Each all-reduce carries n=16n = 16 KiB. Bytes: 148×16,384450×109=64\frac{14}{8}\times\frac{16{,}384}{450\times10^9} = 64 ns. Latency: 14×α14\times\alpha, so 2828 to 140 μ140\ \mus for α\alpha between 2 and 10 μ\mus. Times 160 all-reduces: 4.5 to 22.4 ms per token, while the bytes alone would take 0.01 ms. A ring is the wrong shape for tiny messages.

Fewer steps for small messages

That is why there are other algorithms. With NVSwitch every GPU can reach every other directly, so a GPU can send each peer its chunk in one step (a one-hop reduce-scatter) and then gather the sums in one more step: two steps, whatever pp is. NVIDIA describes this for TensorRT-LLM (their "MultiShot" all-reduce, which uses the switch to multicast):

"This process is repeated 2N-2 times where N is the number of GPUs working together ... This increases latency, as all GPUs need to stay synchronized at every step of the ring." (NVIDIA technical blog, 3x Faster AllReduce with NVSwitch and TensorRT-LLM MultiShot, November 2024)

The two-step version costs about

Ttwo-step≈2α+2(p−1)p⋅nβT_{\text{two-step}} \approx 2\alpha + \frac{2(p-1)}{p}\cdot\frac{n}{\beta}

with the same symbols as before. Same bytes, but only two delays. For the 70B example: 160×2×α160\times2\times\alpha = 0.65 to 3.2 ms per token, about 7x less than the ring at TP=8. (NCCL itself also switches algorithm and protocol by message size; the point is that small all-reduces are a latency problem, and good implementations attack the step count.)

Llama 3.1 70B, all 160 all-reduces of one decode stepRingTwo-stepBytes only
TP=2, batch 1, NVLink0.65 to 3.21 ms0.65 to 3.21 ms0.006 ms
TP=8, batch 1, NVLink4.49 to 22.41 ms0.65 to 3.21 ms0.010 ms
TP=8, batch 64, NVLink5.13 to 23.05 ms1.29 to 3.85 ms0.65 ms
TP=8, batch 64, PCIe Gen59.07 to 26.99 ms5.23 to 7.79 ms4.59 ms
TP=8, batch 64, one 400 Gb/s NIC10.35 to 28.27 ms6.51 to 9.07 ms5.87 ms

Ranges are for α\alpha = 2 to 10 μ\mus (assumed); bandwidths are per-direction peaks. Read the table by rows:

  • At batch 1 the bytes never matter; only the number of steps does.
  • At batch 64 the bytes start to count, and on PCIe or the network they alone cost 4.6 to 5.9 ms per token, as much as reading the weights. That is why the vLLM documentation tells you to avoid tensor parallelism on GPUs without NVLink (Section 8).

5. Pipeline parallelism: split the layers instead

The idea, from training

Pipeline parallelism communicates far less than tensor parallelism. Between two stages it sends the activations once (b×d×sb\times d\times s bytes, point to point), instead of two all-reduces in every layer. The cost is idle time. GPipe (Huang et al., 2019) introduced the standard picture:

GPipe gives the size of the bubble:

Worked example. K=4K = 4 stages. With M=1M = 1: 34=75%\frac{3}{4} = 75\% idle. With M=16M = 16: 319=16%\frac{3}{19} = 16\%.

Pipelines during decode

Decode adds a twist: the next token of a sequence cannot start until its previous token has left the last stage. One sequence alone keeps only one stage busy at a time. Several independent groups of sequences (micro-batches) fill the gaps. layout_model.py lays out the schedule for 4 stages, each micro-batch generating 3 tokens:

one sequence (M = 1): each stage works 1 slot in 4: 3 tokens in 12 slots, GPUs busy 25%GPU 0mb0mb0mb0GPU 1mb0mb0mb0GPU 2mb0mb0mb0GPU 3mb0mb0mb0four micro-batches in flight (M = 4): 12 tokens in 15 slots, GPUs busy 80%GPU 0mb0mb1mb2mb3mb0mb1mb2mb3mb0mb1mb2mb3GPU 1mb0mb1mb2mb3mb0mb1mb2mb3mb0mb1mb2mb3GPU 2mb0mb1mb2mb3mb0mb1mb2mb3mb0mb1mb2mb3GPU 3mb0mb1mb2mb3mb0mb1mb2mb3mb0mb1mb2mb3A token must pass all 4 stages before the next token of the same sequence can start.
Pipeline schedule from layout_model.py: 4 GPUs, each stage taking one time slot. One sequence keeps each GPU busy a quarter of the time; four micro-batches keep them 80% busy.

The figure shows the two faces of pipelining:

  • Throughput improves with micro-batches: 12 tokens in 15 slots instead of 3 tokens in 12.
  • Latency does not. Each token still passes through all 4 stages one after another, each stage running only its quarter of the layers. A pipeline never makes one sequence's token faster than reading all its layers on one GPU would, and it adds a network hop between stages.

Meta's choice for Llama 3 405B, from the same section as the box in Section 1, follows from this:

Worked example: 405B on two nodes

layout_model.py compares two ways to use 16 H100s in two nodes for Llama 3.1 405B:

  • TP=16: every layer split 16 ways, so 252 all-reduces per step, all crossing InfiniBand (α\alpha assumed 10 μ\mus, β\beta = 50 GB/s per GPU).
  • TP=8 x PP=2: each node holds 63 layers, split 8 ways over NVLink; one hop over InfiniBand per step.
Llama 3.1 405B (BF16) on two 8-GPU nodes, modelledms per token (latency)tokens/s (throughput)batch 1: TP=16 over InfiniBandbatch 1: TP=16 over InfiniBand: 20.5 ms20.5batch 1: TP=16 over InfiniBand: 49 tokens/s49batch 1: TP=8 x PP=2, 1 micro-batchbatch 1: TP=8 x PP=2, 1 micro-batch: 32.9 ms32.9batch 1: TP=8 x PP=2, 1 micro-batch: 30 tokens/s30batch 1: TP=8 x PP=2, 2 micro-batchesbatch 1: TP=8 x PP=2, 2 micro-batches: 32.9 ms32.9batch 1: TP=8 x PP=2, 2 micro-batches: 61 tokens/s61batch 32: TP=16 over InfiniBandbatch 32: TP=16 over InfiniBand: 31.4 ms31.4batch 32: TP=16 over InfiniBand: 1,021 tokens/s1,021batch 32: TP=8 x PP=2, 1 micro-batchbatch 32: TP=8 x PP=2, 1 micro-batch: 36.4 ms36.4batch 32: TP=8 x PP=2, 1 micro-batch: 879 tokens/s879batch 32: TP=8 x PP=2, 2 micro-batchesbatch 32: TP=8 x PP=2, 2 micro-batches: 36.4 ms36.4batch 32: TP=8 x PP=2, 2 micro-batches: 1,760 tokens/s1,760Pipeline: no faster per token, but more tokens per second. TP=16: lower latency, many network messages.
Modelled Llama 3.1 405B decode on two 8-GPU nodes. Pipelining does not lower latency, but with two micro-batches in flight it gives the highest throughput.
Batch 32ms per tokenTokens per second
TP=16 across both nodes31.4 (15.0 of it all-reduce over InfiniBand)1,020
TP=8 x PP=2, one micro-batch36.4879
TP=8 x PP=2, two micro-batches36.41,760

Per token, the pipeline is slower: each token reads 406 GB on 8 GPUs (15.1 ms) in the first node, then again in the second, plus the hop. TP=16 reads everything in parallel on 16 GPUs but spends almost half its time in all-reduces over the network. Fill the pipeline with two micro-batches and it delivers 1.7x the throughput of TP=16 while sending a few kilobytes per token over InfiniBand instead of hundreds of messages. If latency matters most and the network is excellent, TP across nodes can win; for throughput per GPU, the pipeline does. Both conclusions depend on the assumed α\alpha, which is why the code keeps it as a named input.

6. Expert parallelism for mixture-of-experts models

Why MoE models need their own kind of parallelism

A mixture-of-experts layer replaces one big MLP with many small ones. DeepSeek-V3 has, in each of its 58 MoE layers, 256 routed experts (each an MLP with a hidden width of 2,048) plus one shared expert that every token uses. A small router scores the experts for each token and sends the token to its top 8.

You could split every expert with tensor parallelism. But each expert is small, and an all-reduce per expert per layer would be pure overhead. The natural move is the opposite: keep each expert whole and give different experts to different GPUs.

Each MoE layer then runs in four stages:

One MoE layer with expert parallelism (EP = 4 GPUs, 2 experts each, top-2)GPU 0: attentionrouter picks 2 experts/tokenexperts 0, 1FFN on received tokensweighted sum of 2 outputsGPU 1: attentionrouter picks 2 experts/tokenexperts 2, 3FFN on received tokensweighted sum of 2 outputsGPU 2: attentionrouter picks 2 experts/tokenexperts 4, 5FFN on received tokensweighted sum of 2 outputsGPU 3: attentionrouter picks 2 experts/tokenexperts 6, 7FFN on received tokensweighted sum of 2 outputsdispatch: all-to-allcombine: all-to-allTwo all-to-alls per MoE layer. Where tokens go depends on the router, so traffic is uneven.
Expert parallelism on four GPUs. Dispatch sends each token's activations to the GPUs holding its chosen experts; combine sends the expert outputs back to be added up.
  1. Each GPU runs attention for its own tokens, and the router picks each token's experts.
  2. Dispatch: an all-to-all sends each token's activations to the GPUs that hold its experts.
  3. Each GPU runs its experts on whatever tokens it received.
  4. Combine: a second all-to-all sends the outputs back, where they are added with the router's weights.

How many bytes

Each token is sent once per chosen expert, and the answers come back the same way.

bytes per token per MoE layer=k d (sdispatch+scombine)\text{bytes per token per MoE layer} = k\,d\,(s_{\text{dispatch}} + s_{\text{combine}})

where:

  • kk is the number of experts chosen per token (8 for DeepSeek-V3);
  • dd is the model width (7,168);
  • sdispatchs_{\text{dispatch}} and scombines_{\text{combine}} are the bytes per number on the way out and back: DeepSeek sends 1 byte (FP8) out and 2 bytes (BF16) back.

Worked example. 8×7,168×(1+2)=172,0328\times7{,}168\times(1+2) = 172{,}032 bytes per token per layer; over 58 MoE layers, 10.0 MB per token. A decode batch of 128 tokens per GPU dispatches 128×8×7,168=7.34128\times8\times7{,}168 = 7.34 MB per layer.

DeepSeek's open-source all-to-all library, DeepEP, published timings for exactly this setting (H800s with 400 Gb/s InfiniBand, 128 tokens per batch, hidden 7,168, top-8, FP8 dispatch, BF16 combine; README of release v1.2.1). Our byte count reproduces them: 7.34 MB at their measured 98 GB/s is 75 μ\mus, against the 77 μ\mus they report for EP8 dispatch; 14.68 MB of combine at 127 GB/s is 116 μ\mus against 114 μ\mus reported. Across all 58 MoE layers that is 11.1 ms per decode step at EP8 and 32.1 ms at EP256 (194 and 360 μ\mus per layer), unless it is hidden behind computation. That is why DeepSeek runs two micro-batches and overlaps one's communication with the other's compute.

Terminal output of collectives.py section 3 and 4: the all-reduce cost table for Llama 3.1 70B, ring against two-step, and the DeepSeek-V3 all-to-all byte arithmetic: 57,344 B dispatch and 114,688 B combine per token per layer, 10.0 MB per token, 75 and 116 microseconds for a 128-token batch against DeepEP's reported 77 and 114, 11.1 ms per decode step at EP8 and 32.1 ms at EP256

The real problem: load imbalance

All-to-all has a second cost that the bytes do not show. A layer is finished only when the busiest GPU is finished. If the router sends twice the average number of tokens to the experts on one GPU, that GPU takes twice as long and every other GPU waits.

How uneven is real routing? Rather than guess, moe_routing.py records it. It runs the real OLMoE-1B-7B model (64 experts per layer, 8 chosen per token, 16 layers, 6.9 billion parameters) on this laptop's GPU over 12,288 tokens of WikiText and 12,288 tokens of Python code, and saves which 8 experts the router chose for every token in every layer. That is about 3 million real routing decisions. The routing is the measurement; nothing is timed.

The recording is a forward hook on each layer's router, which returns the chosen expert ids as its third output:

python
for i, layer in enumerate(model.model.layers):
    hooks.append(layer.mlp.gate.register_forward_hook(
        lambda m, inp, out, i=i: buf.setdefault(i, []).append(out[2].cpu())))
with torch.no_grad():
    for s in seqs:
        model(torch.tensor(s, device=dev)[None])

Then plain Python asks what those choices would do to expert parallelism. Experts 00 to 6363 are placed in order, 64/G64/G per GPU, and for random batches of tokens we compute the load on the busiest GPU divided by the average load:

python
counts = np.bincount(picks[l, idx].ravel(), minlength=E)   # tokens sent to each expert in this batch
load = np.zeros(n_gpus)
np.add.at(load, expert_to_gpu, counts)                       # tokens each GPU must process
ratios.append(load.max() / load.mean())                      # 1.00 = perfectly even

The same calculation on random routing (each token picks 8 experts uniformly) separates bad luck from real preference.

Terminal output of moe_routing.py: for WikiText and Python code, how much more than its fair share the hottest expert gets, the busiest-GPU-over-average ratio for EP = 8, 16 and 64 at batches of 64, 256 and 4,096 tokens against random routing, the effect of balanced placement and redundant experts, and how many hot experts the two kinds of text share

First, the experts themselves. In a typical layer the busiest expert receives 3.1 times its fair share of WikiText tokens (2.2x to 4.2x across layers), and on code 6.9 times; the idlest receive almost nothing.

Layer 8 (a typical layer), WikiText: share of the 8 expert slots per tokenexpert rank 1: 4.86% of visitsexpert rank 2: 4.01% of visitsexpert rank 3: 3.79% of visitsexpert rank 4: 3.13% of visitsexpert rank 5: 2.74% of visitsexpert rank 6: 2.50% of visitsexpert rank 7: 2.43% of visitsexpert rank 8: 2.41% of visitsexpert rank 9: 2.39% of visitsexpert rank 10: 2.34% of visitsexpert rank 11: 2.19% of visitsexpert rank 12: 2.18% of visitsexpert rank 13: 2.16% of visitsexpert rank 14: 2.10% of visitsexpert rank 15: 2.07% of visitsexpert rank 16: 2.01% of visitsexpert rank 17: 2.01% of visitsexpert rank 18: 1.99% of visitsexpert rank 19: 1.95% of visitsexpert rank 20: 1.86% of visitsexpert rank 21: 1.77% of visitsexpert rank 22: 1.75% of visitsexpert rank 23: 1.72% of visitsexpert rank 24: 1.70% of visitsexpert rank 25: 1.70% of visitsexpert rank 26: 1.64% of visitsexpert rank 27: 1.63% of visitsexpert rank 28: 1.63% of visitsexpert rank 29: 1.61% of visitsexpert rank 30: 1.57% of visitsexpert rank 31: 1.56% of visitsexpert rank 32: 1.47% of visitsexpert rank 33: 1.45% of visitsexpert rank 34: 1.42% of visitsexpert rank 35: 1.41% of visitsexpert rank 36: 1.41% of visitsexpert rank 37: 1.40% of visitsexpert rank 38: 1.40% of visitsexpert rank 39: 1.37% of visitsexpert rank 40: 1.36% of visitsexpert rank 41: 1.34% of visitsexpert rank 42: 1.26% of visitsexpert rank 43: 1.25% of visitsexpert rank 44: 1.14% of visitsexpert rank 45: 1.06% of visitsexpert rank 46: 1.03% of visitsexpert rank 47: 1.00% of visitsexpert rank 48: 0.97% of visitsexpert rank 49: 0.94% of visitsexpert rank 50: 0.89% of visitsexpert rank 51: 0.82% of visitsexpert rank 52: 0.80% of visitsexpert rank 53: 0.72% of visitsexpert rank 54: 0.72% of visitsexpert rank 55: 0.66% of visitsexpert rank 56: 0.54% of visitsexpert rank 57: 0.53% of visitsexpert rank 58: 0.48% of visitsexpert rank 59: 0.46% of visitsexpert rank 60: 0.44% of visitsexpert rank 61: 0.32% of visitsexpert rank 62: 0.30% of visitsexpert rank 63: 0.14% of visitsexpert rank 64: 0.06% of visitsfair 1/64hottest: 3.1x fairLayer 4 (the most uneven layer), WikiText: share of the 8 expert slots per tokenexpert rank 1: 6.58% of visitsexpert rank 2: 5.10% of visitsexpert rank 3: 3.06% of visitsexpert rank 4: 2.85% of visitsexpert rank 5: 2.72% of visitsexpert rank 6: 2.69% of visitsexpert rank 7: 2.54% of visitsexpert rank 8: 2.28% of visitsexpert rank 9: 2.23% of visitsexpert rank 10: 2.21% of visitsexpert rank 11: 2.17% of visitsexpert rank 12: 2.13% of visitsexpert rank 13: 2.07% of visitsexpert rank 14: 1.96% of visitsexpert rank 15: 1.90% of visitsexpert rank 16: 1.90% of visitsexpert rank 17: 1.88% of visitsexpert rank 18: 1.85% of visitsexpert rank 19: 1.83% of visitsexpert rank 20: 1.82% of visitsexpert rank 21: 1.79% of visitsexpert rank 22: 1.77% of visitsexpert rank 23: 1.73% of visitsexpert rank 24: 1.72% of visitsexpert rank 25: 1.65% of visitsexpert rank 26: 1.61% of visitsexpert rank 27: 1.61% of visitsexpert rank 28: 1.55% of visitsexpert rank 29: 1.55% of visitsexpert rank 30: 1.53% of visitsexpert rank 31: 1.53% of visitsexpert rank 32: 1.53% of visitsexpert rank 33: 1.52% of visitsexpert rank 34: 1.50% of visitsexpert rank 35: 1.47% of visitsexpert rank 36: 1.46% of visitsexpert rank 37: 1.35% of visitsexpert rank 38: 1.34% of visitsexpert rank 39: 1.30% of visitsexpert rank 40: 1.30% of visitsexpert rank 41: 1.25% of visitsexpert rank 42: 1.22% of visitsexpert rank 43: 1.22% of visitsexpert rank 44: 1.14% of visitsexpert rank 45: 1.14% of visitsexpert rank 46: 1.05% of visitsexpert rank 47: 1.04% of visitsexpert rank 48: 1.01% of visitsexpert rank 49: 0.92% of visitsexpert rank 50: 0.92% of visitsexpert rank 51: 0.90% of visitsexpert rank 52: 0.88% of visitsexpert rank 53: 0.78% of visitsexpert rank 54: 0.72% of visitsexpert rank 55: 0.71% of visitsexpert rank 56: 0.67% of visitsexpert rank 57: 0.54% of visitsexpert rank 58: 0.35% of visitsexpert rank 59: 0.24% of visitsexpert rank 60: 0.19% of visitsexpert rank 61: 0.16% of visitsexpert rank 62: 0.14% of visitsexpert rank 63: 0.13% of visitsexpert rank 64: 0.10% of visitsfair 1/64hottest: 4.2x fairMeasured: OLMoE-1B-7B (64 experts, top-8) routing 12,288 WikiText tokens. Experts sorted by load.
Measured: share of all expert slots each of the 64 experts receives in one layer of OLMoE-1B-7B, sorted. The dashed line is a perfectly fair 1/64.

Then what that does to GPUs:

random routing (bad luck only)real routing, WikiText (measured)EP=8, 64 tokensEP=8 batch 64: random 1.17EP=8 batch 64: real 1.351.171.35xEP=8, 4,096 tokensEP=8 batch 4096: random 1.02EP=8 batch 4096: real 1.321.021.32xEP=16, 64 tokensEP=16 batch 64: random 1.30EP=16 batch 64: real 1.641.301.64xEP=16, 4,096 tokensEP=16 batch 4096: random 1.04EP=16 batch 4096: real 1.571.041.57xEP=64, 64 tokensEP=64 batch 64: random 1.85EP=64 batch 64: real 3.321.853.32xEP=64, 4,096 tokensEP=64 batch 4096: random 1.10EP=64 batch 4096: real 3.181.103.18xBusiest GPU / average GPU. 1.00 would be perfect. The whole layer waits for the busiest GPU.
Busiest GPU divided by the average GPU, from OLMoE's real routing on WikiText against random routing. Experts placed in order, 64/EP per GPU.
Busiest GPU / average, batches of 4,096 tokensEP=8EP=16EP=64
Random routing (chance only)1.021.041.10
Real routing, WikiText1.321.573.18
Real routing, Python code1.802.616.77

Three lessons:

  • Big batches do not save you. Random imbalance fades as batches grow (1.17 at 64 tokens, 1.02 at 4,096 for EP=8). Real imbalance does not (1.35 and 1.32): it comes from the router's genuine preferences, not from small numbers.
  • Wider EP makes it worse. At EP=64 each GPU holds one expert, so the busiest GPU is simply the hottest expert: 3.2x the average on WikiText, 6.8x on code. Two thirds or more of the GPU time in the layer is waiting.
  • The hot experts depend on the traffic. WikiText and code share a median of 1 of their 8 hottest experts per layer. A placement tuned on yesterday's chat traffic can be wrong for today's coding traffic.

(OLMoE was trained with a load-balancing loss, like most MoE models, and DeepSeek-V3 adds its own auxiliary-loss-free balancing. Training reduces the skew, but as these numbers show, it does not remove it at serving time, when the mix of text is whatever users send.)

The fix: place experts by load, and copy the hot ones

DeepSeek's report describes what they do in production:

moe_routing.py tries the same two ideas on OLMoE's routing. It learns from the first half of the recorded tokens and is tested on the second half, just as a server must use past load to plan for future load:

  1. Placement by load: put the heaviest experts first, each on the least-loaded GPU that still has room.
  2. Redundant copies: each GPU gets a few spare slots; repeatedly copy the busiest expert on the busiest GPU to the least-loaded GPU with a free slot (keeping a copy only if it does not create a new busiest GPU), and split that expert's tokens evenly between its copies.
experts in orderplaced by past loadplus redundant copiesEP=8, 8 copiesEP8+8 default: 1.341.34EP8+8 balanced: 1.281.28EP8+8 redundant: 1.291.29EP=8, 16 copiesEP8+16 default: 1.341.34EP8+16 balanced: 1.281.28EP8+16 redundant: 1.291.29EP=16, 16 copiesEP16+16 default: 1.581.58EP16+16 balanced: 1.471.47EP16+16 redundant: 1.471.47EP=64, 64 copiesEP64+64 default: 3.193.19EP64+64 balanced: 3.193.19EP64+64 redundant: 2.772.77Simulated on measured routing: placement learned on the first half of the text, tested on the second half.
Simulated on measured routing (WikiText, batches of 4,096 tokens): experts in order, placed by past load, and with redundant copies. Learned on the first half of the text, tested on the second.
Busiest / average, tested on unseen tokensIn orderPlaced by loadPlus redundant copies
WikiText, EP=8 (8 copies)1.341.281.29
WikiText, EP=16 (16 copies)1.581.471.47
WikiText, EP=64 (64 copies)3.193.192.77
Code, EP=8 (8 copies)1.811.121.12
Code, EP=16 (16 copies)2.621.741.37
Code, EP=64 (64 copies)6.756.751.67

When a GPU holds several experts, placing them by load does most of the work (code at EP=8: 1.81 to 1.12). When a GPU holds only one expert, placement cannot help at all, and only copies help (code at EP=64: 6.75 to 1.67). The leftover imbalance on WikiText comes from the traffic changing between the two halves of the text, which is exactly why DeepSeek refreshes its choice every 10 minutes and is "exploring a dynamic redundancy strategy" (same section) that re-plans for every batch.

DeepSeek-V3 in production

Putting the pieces together, here is how DeepSeek serves V3, with prefill and decode on separate groups of machines (Section 7 explains why):

DeepSeek-V3 serving on H800s (technical report, Section 3.4)Prefill unit: 4 nodes, 32 GPUsattention: TP4 + SP, DP8MoE: EP32, 8 experts + 1 redundant per GPUtwo micro-batches overlap compute and all-to-allDecode unit: 40 nodes, 320 GPUsattention: TP4 + SP, DP80MoE: EP320, 1 expert per GPU (64 GPUs for redundant + shared)point-to-point IB transfers, IBGDAPrefill and decode run on separate units: disaggregation plus very wide expert parallelism.
DeepSeek-V3's deployment units as described in its technical report. Prefill uses 32 GPUs per unit; decode uses 320, with one expert per GPU.

DeepSeek later published what this looks like in daily operation. Their inference system overview (February 2025) describes a slightly different production layout than the paper, EP32 for prefill over 4 nodes and EP144 for decode over 18 nodes, each with 32 redundant experts, and reports the results over one day:

DeepSeek inference system overview: Prefilling Phase [Routed Expert EP32, MLA/Shared Expert DP32]: Each deployment unit spans 4 nodes with 32 redundant routed experts, where each GPU handles 9 routed experts and 1 shared expert. Decoding Phase [Routed Expert EP144, MLA/Shared Expert DP144]: Each deployment unit spans 18 nodes with 32 redundant routed experts, where each GPU manages 2 routed experts and 1 shared expert.

DeepSeek inference system overview statistics: total input tokens 608B, of which 342B tokens (56.3%) hit the on-disk KV cache; total output tokens 168B; average output speed 20 to 22 tokens per second; each H800 node delivers an average throughput of about 73.7k tokens/s input including cache hits during prefilling or about 14.8k tokens/s output during decoding.

Each H800 node delivered about 73.7 thousand input tokens per second in prefill (including cache hits) or about 14.8 thousand output tokens per second in decode. Prefill nodes move five times more tokens, which is the compute-bound against memory-bound gap of Part 1 showing up at cluster scale, and one more reason to give the two phases different machines.

7. Disaggregated prefill and decode

Why the two phases get in each other's way

Part 1 showed that prefill and decode are different kinds of work. Prefill processes thousands of prompt tokens at once and is limited by arithmetic (compute-bound). Decode produces one token per sequence per step and is limited by reading memory (memory-bound). Part 3 showed what happens when one GPU does both: a long prompt arriving in the middle of other users' answers either stalls those answers while it is prefilled, or, with chunked prefill, is cut into slices that slow every step a little for longer.

The cost model of the simulator below makes the interference concrete. A user is one of 32 sequences being decoded on an H100 running Llama 3.1 8B when another user's 2,000-token prompt arrives:

What one user sees while a 2,000-token prompt arrives (cost model of disagg_sim.py)colocated, prefill firsttoktokprefill 68 ms: no tokenstoktokcolocated, chunked (512)toktokdisaggregated: decode GPUtoktoktoktoktoktoktokdecode step 11.4 ms (batch 32); with a 512-token chunk 19.3 ms; whole prefill 68 msThe prompt belongs to another user; ours is one of the 32 sequences already decoding.
What one decoding user sees when someone else's long prompt arrives, from the simulator's cost model (roofline with assumed efficiencies, Llama 3.1 8B on an H100).

With prefill first, the user's stream freezes for the whole 67.8 ms prefill, six normal steps long. With 512-token chunks, four of the user's steps grow from 11.4 ms to 19.3 ms. On a decode-only GPU nothing happens at all.

The papers: Splitwise and DistServe

Two papers in 2024 made the case for splitting the phases onto separate GPUs. Splitwise (Patel et al., Microsoft and the University of Washington) started from production traces and the observation that decode does not need the newest, most compute-heavy GPUs; it reported clusters with "up to 1.4x higher throughput at 20% lower cost". DistServe (Zhong et al., OSDI 2024) framed the goal as goodput, defined in Part 6 (coming soon): the highest request rate at which a target share of requests still meets both the time-to-first-token (TTFT) and time-per-output-token (TPOT) objectives. Its Figure 1 is the clearest picture of the problem:

Notice what the baseline was: "existing systems" in early 2024 meant vLLM without chunked prefill. That matters for our own simulation below.

The cost: moving the KV cache

Splitting the phases adds one job: the prompt's KV cache, built on the prefill GPU, must be copied to the decode GPU before the second token. Its size is the bytes-per-token of Section 1 times the prompt length, and its transfer time is size over bandwidth:

ttransfer=2 L HKV dh bkv×Tβt_{\text{transfer}} = \frac{2\,L\,H_{\mathrm{KV}}\,d_h\,b_{kv}\times T}{\beta}

where:

  • TT is the number of prompt tokens and the numerator is the KV cache in bytes (Section 1);
  • β\beta is the bandwidth of the path between the two GPUs.

DistServe works one example; our script reproduces it first, to check the formula:

Worked example (check). OPT-66B has 64 layers and full multi-head attention with width 9,216, so 2×64×9,216×2=2,359,2962\times64\times9{,}216\times2 = 2{,}359{,}296 bytes per token; times 512 tokens is 1,207,959,5521{,}207{,}959{,}552 bytes = 1.125 GiB, and at 10 per second, 90 Gibit/s. The paper's 1.13GB and 90Gbps are the same numbers counted in powers of two.

Worked example (today). Llama 3.1 70B, an 8,192-token prompt: 320 KiB×8,192=2.5320\ \text{KiB}\times8{,}192 = 2.5 GiB. Over one 400 Gb/s card (50 GB/s): 54 ms. Over NVLink in the same server: 6 ms. If the prefill side runs TP=8 and the decode side runs TP=8, each GPU holds one eighth of the cache and can send it over its own network card, so the 8 cards together move it in 6.7 ms.

1 ms10 ms100 ms1,000 msLlama 3.1 8B (1 GPU): 1.00 GiB of KVprefill itself (~)prefill itself (~): 301.6 ms301.6NVLink (same node)NVLink (same node): 2.4 ms2.48 x 400G NICs8 x 400G NICs: 2.7 ms2.71 x 400G NIC1 x 400G NIC: 21.5 ms21.5100G Ethernet100G Ethernet: 85.9 ms85.9Llama 3.1 70B (TP=8): 2.50 GiB of KVprefill itself (~)prefill itself (~): 314.4 ms314.4NVLink (same node)NVLink (same node): 6.0 ms6.08 x 400G NICs8 x 400G NICs: 6.7 ms6.71 x 400G NIC1 x 400G NIC: 53.7 ms53.7100G Ethernet100G Ethernet: 214.7 ms214.7DeepSeek-V3 MLA (32 GPUs): 0.54 GiB of KVprefill itself (~)prefill itself (~): 42.0 ms42.0NVLink (same node)NVLink (same node): 1.3 ms1.38 x 400G NICs8 x 400G NICs: 1.4 ms1.41 x 400G NIC1 x 400G NIC: 11.5 ms11.5100G Ethernet100G Ethernet: 46.1 ms46.1Arithmetic: BF16 cache, peak link bandwidth, prefill at 50% of H100 peak FLOP/s. Log scale.
Moving the KV cache of one 8,192-token prompt, against the time to prefill it. Arithmetic with peak link bandwidths; prefill assumed at 50% of H100 peak.

Terminal output of kv_transfer.py: the DistServe check (1.125 GiB, 90 Gibit/s), then KV size, prefill time and transfer time over NVLink, eight 400G NICs, one 400G NIC and 100G Ethernet for Llama 3.1 8B, Llama 3.1 70B and DeepSeek-V3 at 1,024, 8,192 and 32,768 tokens, and the extra delay before the second token when sending at the end or layer by layer

Two things make this cheaper than it looks. First, MLA: DeepSeek-V3's 8,192-token cache is only 0.54 GiB, a fifth of 70B's. Second, the transfer does not have to wait for the prefill to finish. Splitwise sends each layer's KV as soon as that layer is done:

With layer-by-layer sending, what is left after the prefill is roughly the larger of one layer's share and the part of the transfer that did not fit under the computation:

texposed≈max⁡ ⁣(ttransferL, ttransfer−tprefill⋅L−1L)t_{\text{exposed}} \approx \max\!\left(\frac{t_{\text{transfer}}}{L},\ t_{\text{transfer}} - t_{\text{prefill}}\cdot\frac{L-1}{L}\right)

where LL is the number of layers and tprefillt_{\text{prefill}} the prefill time. For the 70B example over one card: the transfer (54 ms) is far shorter than the prefill (about 314 ms at the assumed efficiency), so only one layer's share is left: 54/80=0.6754/80 = 0.67 ms.

Llama 3.1 70B, 8,192-token prompt, KV over one 400 Gb/s NIC (54 ms of transfer)send at the endprefill 314 ms+542nd tokenlayer by layerprefill 314 ms2nd token (+0.7 ms)Each layer's KV leaves while the next layers compute, so only the last layer's share is left at the end.
The same 2.5 GiB transfer sent after the prefill or layer by layer. Arithmetic from kv_transfer.py.

Splitwise measured the effect on real hardware:

Mooncake: build the system around the KV cache

Moonshot AI's Mooncake, the serving platform behind their Kimi assistant, takes the idea one step further. If KV caches are going to travel between machines anyway, treat them as the central object: keep them in a pool spread across the cluster's spare CPU memory and SSDs, and schedule every request by where its cache already is.

DeepSeek's production numbers from Section 6 show the same pattern at scale: 56.3% of their input tokens hit an on-disk KV cache.

A simulation: when is it worth it?

The papers report big wins against the systems of their time. Engines have improved since, especially with chunked prefill. So disagg_sim.py compares the options on equal hardware, under clearly stated assumptions:

  • 4 H100s serving Llama 3.1 8B. Each iteration costs max⁡(bytes/(0.7×3.35 TB/s), FLOPs/(0.5×989 TFLOP/s))+1\max(\text{bytes}/(0.7 \times 3.35\ \text{TB/s}),\ \text{FLOPs}/(0.5\times989\ \text{TFLOP/s})) + 1 ms, with attention FLOPs counted for prompts. The 70% and 50% efficiencies and the 1 ms overhead are assumptions.
  • Layouts: four colocated GPUs with prefill first; four colocated GPUs with 512-token chunked prefill; and disaggregated 1+3, 2+2 and 3+1 prefill and decode GPUs, with the KV cache moved over NVLink.
  • Workloads: "chat" with 1,000 to 3,000-token prompts and 100 to 400-token answers; "long" with 8,000 to 16,000-token prompts and 50 to 200-token answers. Poisson arrivals for 120 simulated seconds.
  • SLOs: loose (TTFT 1 s, TPOT 40 ms), tight (TTFT 0.5 s, TPOT 15 ms) and strict (TTFT 1 s, TPOT 12 ms). Goodput is the highest rate at which 90% of requests meet both.

The heart of the simulator is the cost of one iteration and the three kinds of iteration a GPU can run:

python
def iter_time(decode_reqs, prefill_tokens, extra_flops=0.0):
    kv = sum(r['ctx'] for r in decode_reqs) * KV_TOK          # every running sequence's cache is read
    toks = len(decode_reqs) + prefill_tokens
    return max((W_BYTES + kv) / HBM, (2 * P * toks + extra_flops) / PEAK) + OVH

A prefill-first GPU runs a whole-prompt iteration whenever prompts are waiting, so everyone decoding waits; a chunked GPU adds up to 512 prompt tokens to every decode iteration; a disaggregated prefill GPU only prefills, then schedules a hand-off event T×128 KiB/450 GB/sT \times 128\ \text{KiB} / 450\ \text{GB/s} later, when the request joins the least-loaded decode GPU.

chat, tight SLO (0.5 s, 15 ms)0%50%90%100%8162432404856requests per second; goodput (90% met):prefill first: 16 req/schunked: 28 req/s2P + 2D: 26 req/s3P + 1D: 14 req/slong prompts, strict SLO (1 s, 12 ms)90%135791113requests per second; goodput (90% met):prefill first: 1 req/schunked: 2 req/s2P + 2D: 3 req/s3P + 1D: 2 req/s
Simulated share of requests meeting the SLO as load grows, on 4 GPUs. Left: chat workload, tight SLO. Right: long-prompt workload, strict SLO. Goodput is where a curve crosses 90%.

Terminal output of disagg_sim.py: for the chat and long workloads, the goodput of each layout under the loose, tight and strict SLOs, with p90 TTFT, p90 TPOT, p99 gap between tokens and SLO attainment at several request rates

Goodput, requests/s on 4 GPUs (simulated)Chat, looseChat, tightChat, strictLong, looseLong, strict
Colocated, prefill first30161041
Colocated, chunked prefill48281862
Disaggregated 1P + 3D12101211
Disaggregated 2P + 2D28262033
Disaggregated 3P + 1D2614852

(No layout meets the tight SLO on the long workload: a 12,000-token prompt alone takes about half a second to prefill on one GPU.)

What the simulation says, honestly:

  • Disaggregation removes the stalls. In the chat workload at 16 requests per second, prefill-first has a 99th-percentile gap between tokens of 99 ms (worst 236 ms); chunked prefill 19 ms; 2P + 2D 12 ms. On long prompts, prefill-first freezes some users for up to 1.4 seconds.
  • But on a handful of GPUs, chunked prefill usually wins on goodput. With only 4 GPUs, the split must be 1+3, 2+2 or 3+1, and every ratio wastes some capacity. Chunked prefill uses all four GPUs for whatever work exists. Under the loose and tight SLOs it serves the most requests on both workloads.
  • Disaggregation wins when the time-per-token target is strict. Under the strict 12 ms TPOT, 2P + 2D serves 20 chat requests per second against 18, and 3 long-prompt requests against 2, because chunked steps that carry prompt slices are slower than pure decode steps.
  • The ratio is everything. The same four GPUs serve 12, 28 or 26 chat requests per second (loose SLO) depending on the split. Production systems pick and adjust the ratio continuously; DistServe searches for it automatically.

The vLLM documentation states the same conclusion in one line:

So disaggregation is worth it when: prompts are long; the per-token latency target is strict; the deployment is large enough to choose the prefill-to-decode ratio finely (DeepSeek runs thousands of GPUs); the two phases benefit from different parallel layouts (DeepSeek's EP32 prefill against EP144 or EP320 decode); and there is fast networking for the KV cache. For one or two servers of a mid-sized dense model, chunked prefill on every GPU is the simpler and often better choice.

8. Real systems today

Everything above is available as flags in the open-source engines. Here is how each idea maps to them, checked against the current documentation (11 October 2026). Flags change between releases, so check the docs for your version.

vLLM

The vLLM docs give the same rule of thumb this article arrived at:

IdeavLLM flagNote
Tensor parallel--tensor-parallel-size 8Inside one NVLink node
Pipeline parallel--pipeline-parallel-size 2Across nodes, with TP inside each
Data parallel (copies)--data-parallel-size 8With EP: attention copied, experts spread
Expert parallel--enable-expert-parallelEP size = TP size x DP size
All-to-all kernels--all2all-backend deepep_low_latencyAlso deepep_high_throughput, allgather_reducescatter (default)
Expert load balancing--enable-eplb, --eplb-config '{...}'Redundant experts, as in Section 6
Disaggregated prefill--kv-transfer-config '{"kv_connector":"NixlConnector","kv_role":"kv_both"}'Connectors include NIXL, Mooncake, LMCache; marked experimental

Typical launches, from the documentation:

bash
# Llama 3.1 70B on one 8-GPU node
vllm serve meta-llama/Llama-3.1-70B-Instruct --tensor-parallel-size 8

# A model too big for one node: TP inside each of 2 nodes, PP across them (after joining the nodes as the docs describe)
vllm serve <model> --tensor-parallel-size 8 --pipeline-parallel-size 2

# DeepSeek-V3 on one node: attention copied 8 ways, experts spread over 8 GPUs
vllm serve deepseek-ai/DeepSeek-V3-0324 --tensor-parallel-size 1 --data-parallel-size 8 --enable-expert-parallel

The expert-parallel page lists the all-to-all backends, split by the two phases exactly as DeepSeek splits them:

vLLM docs: vLLM provides multiple communication backends for EP, selected with --all2all-backend: allgather_reducescatter, the default, standard all2all using allgather and reducescatter primitives, general purpose; deepep_high_throughput for multi-node prefill, grouped GEMM with continuous layout, for prefill-dominated high-throughput workloads; deepep_low_latency for multi-node decode, CUDA graph support and masked layout, for decode-dominated low-latency workloads.

SGLang

IdeaSGLang flagNote
Tensor parallel--tp-size (or --tensor-parallel-size)
Pipeline parallel--pp-size
Data parallel copies--dp-size
Data-parallel attention--attn-dp-sizeReplaces the older --enable-dp-attention, now deprecated
Expert parallel--ep-size
All-to-all kernels--moe-a2a-backend deepepAlso mooncake, nixl, flashinfer and others
Expert load balancing--enable-eplb
Disaggregation--disaggregation-mode prefill or decodeTransfer with --disaggregation-transfer-backend mooncake (default) or nixl

SGLang's PD disaggregation page explains the motivation in the terms of Section 7, including a problem specific to data-parallel attention:

SGLang docs: Large Language Model inference comprises two distinct phases: Prefill and Decode. The Prefill phase is computation-intensive, processing the entire input sequence, while the Decode phase is memory-intensive, managing the KV cache for token generation. Traditionally these phases are handled within a unified engine, where combined scheduling of prefill and decode batches introduces inefficiencies. Issues with unified scheduling: 1. Prefill interruption: incoming prefill batches frequently interrupt ongoing decode batches, causing substantial delays in token generation. 2. DP attention imbalance: in data-parallel attention, one DP worker may process a prefill batch while another handles a decode batch simultaneously, leading to increased decode latency. PD Disaggregation resolves these by separating the two stages, enabling tailored optimizations for each. Currently, we support Mooncake and NIXL as the transfer engine.

A single-node disaggregated setup from that page (Llama 3.1 8B, one GPU each, Mooncake transfer) is three commands:

bash
python -m sglang.launch_server --model-path meta-llama/Llama-3.1-8B-Instruct \
  --disaggregation-mode prefill --port 30000 --disaggregation-ib-device mlx5_roce0
python -m sglang.launch_server --model-path meta-llama/Llama-3.1-8B-Instruct \
  --disaggregation-mode decode --port 30001 --base-gpu-id 1 --disaggregation-ib-device mlx5_roce0
python -m sglang_router.launch_router --pd-disaggregation \
  --prefill http://127.0.0.1:30000 --decode http://127.0.0.1:30001 --host 0.0.0.0 --port 8000

The router receives each request, sends it to a prefill server, and has the decode server pick up the KV cache over RDMA (the mlx5_roce0 device is the RDMA network card). The page's DeepSeek-V3 example uses two prefill nodes with --tp-size 16 --attn-dp-size 8 --moe-a2a-backend deepep: attention in data-parallel groups, experts over all 16 GPUs with DeepEP.

The orchestration layer: Dynamo and llm-d

Running disaggregation and wide expert parallelism in production needs more than one engine process: routers that know where each KV cache lives, a way to move KV between machines, and autoscaling of the prefill and decode pools separately. Two open-source projects package this around the engines:

  • NVIDIA Dynamo describes itself as "the open-source, datacenter-scale inference stack". It runs on top of SGLang, TensorRT-LLM or vLLM ("it doesn't replace" them) and adds disaggregated prefill and decode pools that scale independently, KV-aware routing ("based on worker load and KV cache overlap"), and a KV block manager that offloads cache from GPU to CPU, SSD and remote storage. Its transfer library, NIXL (NVIDIA Inference Xfer Library), is also one of vLLM's KV connectors and SGLang's transfer backends. Latest release at the time of writing: v1.5.1, 7 October 2026.
  • llm-d, a Cloud Native Computing Foundation sandbox project started by Red Hat, Google Cloud, IBM Research, CoreWeave and NVIDIA, is "a high-performance distributed inference serving stack optimized for production deployments on Kubernetes". Its documented "well-lit paths" include prefix-cache-aware routing, prefill/decode disaggregation, and "wide expert parallelism" for large MoE models. Latest release: v0.10.0, 29 September 2026.

Both build on vLLM (and in Dynamo's case SGLang and TensorRT-LLM as well); neither replaces the parallelism inside an engine. They decide which engine instance a request goes to and where its KV cache moves, the cluster-level version of the routing in Part 6 (coming soon).

9. Choosing a layout

A decision guide

Do weights + KV for your traffic fit on one GPU?Yes: one GPU per replicaadd replicas for more load (Part 6)No: does it fit on one node?Yes: tensor parallel inside the nodecompare TP sizes by KV room and latencyNo: TP in node+ PP acrossMixture of experts? Spread the experts with EP; run attention data-parallel.Long prompts, strict time per token, many GPUs? Split prefill and decode onto separate GPUs.Then measure: the right layout is the one that meets your SLO at the lowest cost per token.
A first decision guide. Every branch is a rule from Sections 1 to 7; the last step is always to measure.

Worked example 1: Llama 3.1 8B

Weights 16 GB, 128 KiB of KV per token. One H100 holds the model plus (72−16) GB/128 KiB≈427,000(72 - 16)\ \text{GB} / 128\ \text{KiB} \approx 427{,}000 tokens of KV cache: 52 conversations of 8,192 tokens. The decode floor is 4.8 ms per step on one GPU. Use one GPU per copy, and add copies for more traffic. Tensor parallelism would only add all-reduces to a model that already fits; at TP=2 the step floor halves to 2.4 ms, which is worth it only if a per-token latency target cannot be met otherwise. Disaggregation, by our simulation, does not raise goodput at this size unless the per-token target is very strict. Everything else about scaling this model is in Part 6 (coming soon).

Worked example 2: Llama 3.1 70B on one 8-GPU node

The weights (141 GB) need at least two GPUs, and realistically more to leave room for KV. With 8 GPUs, three layouts are possible: one copy at TP=8, two copies at TP=4, or four copies at TP=2. The model in layout_model.py compares them for 4,096-token conversations:

02,0004,0006,0008,00010,00012,0000 ms5 ms10 ms15 ms20 mstokens per second for the whole nodeTP=8 x 1, batch 1 per replica: 6.9 ms, 144 tok/sTP=8 x 1, batch 2 per replica: 7.0 ms, 286 tok/sTP=8 x 1, batch 4 per replica: 7.1 ms, 563 tok/sTP=8 x 1, batch 8 per replica: 7.3 ms, 1,089 tok/sTP=8 x 1, batch 16 per replica: 7.8 ms, 2,044 tok/sTP=8 x 1, batch 32 per replica: 8.8 ms, 3,639 tok/sTP=8 x 1, batch 64 per replica: 10.7 ms, 5,969 tok/sTP=8 x 1, batch 128 per replica: 14.6 ms, 8,779 tok/sTP=8 x 1, batch 256 per replica: 22.3 ms, 11,482 tok/sTP=8 x 1 (max 324/replica)TP=4 x 2, batch 1 per replica: 12.2 ms, 163 tok/sTP=4 x 2, batch 2 per replica: 12.3 ms, 324 tok/sTP=4 x 2, batch 4 per replica: 12.6 ms, 637 tok/sTP=4 x 2, batch 8 per replica: 13.0 ms, 1,231 tok/sTP=4 x 2, batch 16 per replica: 13.9 ms, 2,307 tok/sTP=4 x 2, batch 32 per replica: 15.6 ms, 4,099 tok/sTP=4 x 2, batch 64 per replica: 19.1 ms, 6,702 tok/sTP=4 x 2 (max 109/replica)TP=2 x 4, batch 1 per replica: 22.9 ms, 175 tok/sTP=2 x 4, batch 2 per replica: 23.1 ms, 347 tok/sTP=2 x 4 (max 2/replica)
Modelled Llama 3.1 70B on one 8-GPU H100 node: time per token against tokens per second for the whole node, as the batch per copy grows. Each curve ends where that layout runs out of KV-cache memory.
LayoutKV room (4,096-token conversations)Batch 1: ms per tokenBatch 64 per copy: ms per token, node tokens/sMost the node can do
TP=8, 1 copy3246.910.7 ms, 5,969 (batch 128: 14.6 ms, 8,779)11,482 tokens/s at 22.3 ms (batch 256)
TP=4, 2 copies109 each, 218 total12.219.1 ms, 6,7026,702 tokens/s at 19.1 ms (batch 64)
TP=2, 4 copies2 each22.9(does not fit)347 tokens/s

Worked numbers for TP=4. Each copy has 4×72=2884\times72 = 288 GB usable; the weights take 141 GB, leaving 147 GB, which at 320 KiB×4,096=1.25320\ \text{KiB}\times4{,}096 = 1.25 GiB per conversation holds 109 conversations. For TP=8 the free memory is 576−141=435576 - 141 = 435 GB: 324 conversations, three times as many, because the weights are stored once instead of twice.

You may have heard "prefer more copies over wider tensor parallelism". For a 70B model on 80 GB GPUs this model says the opposite. At the same 128 sequences in flight on the node, one TP=8 copy makes 8,779 tokens per second at 14.6 ms per token, while two TP=4 copies (64 each) make 6,702 at 19.1 ms. Decode is memory-bound, and two copies read the 141 GB of weights twice per step where one copy reads them once. The extra all-reduces of TP=8 cost less than that second read. TP=8 also has three times the KV room, so it can go on to 11,482 tokens per second at batch 256, and it gives the lowest latency for a single user (6.9 against 12.2 ms). TP=2 barely fits the weights and is useless.

More copies do win in other conditions: when the weights are a small part of each step's memory traffic (long contexts, where the KV cache dominates), when the links are slow (no NVLink, so all-reduce is expensive), and for reasons a roofline ignores, such as isolating failures and scheduling requests independently. So the lesson is a method, not a rule: count the KV room for each layout first, then compare latency and throughput at the batch sizes your traffic needs, and confirm with a benchmark as in Part 6.

Worked example 3: a large MoE across nodes

DeepSeek-V3 in FP8 needs 671 GB for weights: at least nine 80 GB GPUs, so more than one node. Its attention is small and its KV cache tiny (68.6 KiB per token), so copying attention is cheap; its experts are huge, so spreading them is the only option. The layouts from Section 6 follow:

  • Small deployment (2 nodes, 16 GPUs): attention in data-parallel groups, experts spread over all 16 GPUs with EP, DeepEP for the all-to-all. This is SGLang's documented two-node example.
  • Large deployment (dozens of nodes): separate prefill and decode units, with wider EP for decode (EP144 to EP320 in DeepSeek's case), redundant copies of hot experts, and two micro-batches to hide the all-to-all.

The all-to-all cost from Section 6 sets the limits. At about 11 ms of dispatch and combine per decode step at EP8 (more at wider EP), the communication must be overlapped with computation, and the network matters as much as the GPUs. That is why DeepSeek limits each token to 4 nodes, and why the decode units are so large: many GPUs reading their own experts in parallel is what makes a 37-billion-active-parameter step fast.

10. Summary

The whole part, on one page

QuestionAnswerWhere the number comes from
Why more than one GPU?70B needs 227 GB with KV for 32 x 8K conversations; 405B BF16 weights (812 GB) exceed one 8-GPU nodeArithmetic from published shapes
What does spreading buy?Each GPU reads 1/p of the weights: 70B decode floor 21.1 ms on 2 GPUs, 5.3 ms on 8Arithmetic, H100 peak bandwidth
How fast are the wires?HBM 3,350, NVLink 900, PCIe 128, one 400G NIC 50 GB/sNVIDIA spec pages
What is a message's cost?α+n/β\alpha + n/\beta: small messages cost a fixed delayMeasured shape on this laptop; NVIDIA publishes no alpha
Tensor parallelismColumns then rows: 2 all-reduces per layer; exact (same 24 tokens, logits within 5×10−55\times10^{-5})Measured: 2-process Qwen2.5-0.5B run
Its cost160 all-reduces per 70B step; 23% of a TP=8 step at batch 1Model, α\alpha assumed 5 μ\mus
Ring all-reduceBytes 2(p−1)pn\frac{2(p-1)}{p}n, but 2(p−1)2(p-1) steps; two-step algorithms cut latency 7x at TP=8Simulation and formula
Pipeline parallelismLittle traffic, no latency gain; with micro-batches 1.7x the throughput of TP=16 across two nodesModel, 405B
Expert parallelism10 MB of all-to-all per token for DeepSeek-V3; real routing makes the busiest GPU 1.3x to 6.8x the averageDeepEP report checked by arithmetic; measured OLMoE routing
Fixing MoE imbalancePlace by load (1.81 to 1.12 at EP=8 on code); copy hot experts (6.75 to 1.67 at EP=64)Simulation on measured routing
DisaggregationMoves 2.5 GiB per 8K-token 70B prompt (54 ms on one NIC, under 1 ms exposed layer by layer); wins under strict TPOT and at scale, not by defaultArithmetic; simulation

Return to where we started. A 70B model does not fit on one GPU, so it is cut into pieces that must talk. Inside a server, where talking is cheap, every layer is split (tensor parallelism) and the GPUs add up their partial results 160 times per token. Between servers, where talking is slow, the model is cut into stages (pipeline parallelism) that pass one message per step. Mixture-of-experts models send each token to the GPUs holding its experts (expert parallelism), and the real difficulty is that some experts are far more popular than others. Finally, the two phases of every request can live on different GPUs (disaggregation), at the price of moving the KV cache, which pays off for long prompts, strict per-token targets and large fleets. In every case the design follows one question: how often must the GPUs talk, and how fast is the wire?

The series so far

PartTopicThe one idea
1Prefill and decodeDecode is limited by reading the weights, not by arithmetic
2The KV cacheSaving keys and values avoids recomputation, and their size limits the batch
3vLLMPaged memory, continuous batching and chunked prefill keep one GPU busy
4Speculative decodingChecking several guessed tokens costs about as much as writing one
5SGLang and vLLMReuse the saved state of shared prompt beginnings
6 (coming soon)Serving in productionMeasure goodput against SLOs, plan capacity, route between copies
7Beyond one GPUSplit the model where the wires are fast; the cost of talking decides the layout

Try it yourself

All the code is in code/multigpu. Nothing needs an NVIDIA GPU.

bash
python code/multigpu/memory_math.py     # Section 1: memory and the decode floor
python code/multigpu/tp_demo.py         # Section 3: split MLP, 2-process Qwen2.5-0.5B, all-reduce timings
python code/multigpu/measure_mps.py     # Section 3: shard timings (needs an Apple GPU; edit dev for CUDA)
python code/multigpu/collectives.py     # Section 4: ring trace, collectives, cost tables, MoE bytes
python code/multigpu/layout_model.py    # Sections 3, 5 and 9: layouts for 70B and 405B
python code/multigpu/moe_routing.py     # Section 6: records OLMoE routing (14 GB download), then simulates EP
python code/multigpu/kv_transfer.py     # Section 7: KV transfer arithmetic
python code/multigpu/disagg_sim.py      # Section 7: colocated vs disaggregated goodput

If you have a machine with two or more NVIDIA GPUs, change the backend in tp_demo.py from gloo to nccl and put each rank's tensors on cuda:<rank>; the same code then runs real tensor parallelism over NVLink or PCIe, and the all-reduce timings in part C become your own α\alpha and β\beta.

References

Papers

  1. M. Shoeybi et al. Megatron-LM: Training Multi-Billion Parameter Language Models Using Model Parallelism. arXiv 1909.08053, 2019.
  2. Y. Huang et al. GPipe: Efficient Training of Giant Neural Networks using Pipeline Parallelism. NeurIPS 2019.
  3. R. Pope et al. Efficiently Scaling Transformer Inference. MLSys 2023.
  4. Llama Team, AI @ Meta. The Llama 3 Herd of Models. 2024. Section 6, Inference.
  5. DeepSeek-AI. DeepSeek-V3 Technical Report. 2024. Sections 3.2.2 and 3.4.
  6. N. Shazeer et al. Outrageously Large Neural Networks: The Sparsely-Gated Mixture-of-Experts Layer. ICLR 2017.
  7. D. Lepikhin et al. GShard: Scaling Giant Models with Conditional Computation and Automatic Sharding. 2020.
  8. P. Patel et al. Splitwise: Efficient Generative LLM Inference Using Phase Splitting. ISCA 2024.
  9. Y. Zhong et al. DistServe: Disaggregating Prefill and Decoding for Goodput-optimized Large Language Model Serving. OSDI 2024.
  10. R. Qin et al. Mooncake: A KVCache-centric Disaggregated Architecture for LLM Serving. 2024.
  11. A. Agrawal et al. SARATHI: Efficient LLM Inference by Piggybacking Decodes with Chunked Prefills. 2023.
  12. N. Muennighoff et al. OLMoE: Open Mixture-of-Experts Language Models. 2024. The model used in Section 6: OLMoE-1B-7B-0924.

Documentation, specifications and engineering posts

  1. NVIDIA. H100, H200, NVLink and NVLink Switch, DGX H100 user guide, InfiniBand adapters (ConnectX-7).
  2. NVIDIA. 3x Faster AllReduce with NVSwitch and TensorRT-LLM MultiShot. Technical blog, November 2024.
  3. NVIDIA. nccl-tests performance notes (algorithm and bus bandwidth).
  4. vLLM. Parallelism and Scaling, Expert Parallel Deployment, Disaggregated Prefilling.
  5. SGLang. Server Arguments, PD Disaggregation.
  6. DeepSeek. DeepEP (performance tables in the v1.2.1 README); DeepSeek-V3/R1 inference system overview, February 2025.
  7. NVIDIA Dynamo and documentation; llm-d and its well-lit paths.

Companion results

  1. Memory arithmetic, tensor-parallel demo, shard timings, collectives, layouts, MoE routing, KV transfer, disaggregation.