When you need this. A team considers separate prefill and decode pools to isolate long-document work from interactive streams. Moving the KV cache between those pools adds a transfer dependency before decode can continue. Estimate that payload before adding orchestration.
What you run. Payload calculator and explicit disaggregation/MoE cost scenarios. No networked KV transfer, multi-GPU model execution, or throughput improvement was measured.
This lesson builds on plan cold starts and gate model rollouts.
Count the cache handoff payload
Prefill/decode disaggregation lets each phase use a different pool and scheduling policy. The receiving decoder needs compatible model state and KV layout. Transfer time depends on payload, effective link bandwidth, contention, and handoff overhead; advertised link bandwidth is only an upper bound.
For a mixture-of-experts (MoE) model, only selected experts execute for a token, but expert weights still need placement. Expert parallelism routes token activations between devices. Uneven expert selection can create stragglers; active parameter count alone does not determine memory or communication cost.
ModelKVSpec calculates transfer payload, and evaluate_pd_disaggregation() exposes bandwidth and overlap assumptions. The MoE calculation is a separate scenario model, not a DeepSeek deployment. The code does not move tensors between real GPUs or validate a KV connector.
Run the transfer budget
Download the lab from this page, extract it, and run these commands from its directory. The default command reads saved run records or runs the explicitly described local algorithm; it never downloads a model or calls a paid API.
python3 lab.py
python3 -m unittest -v test_lab.pyRead lab.py
"""Disaggregated prefill/decode pool sizer and large Mixture-of-Experts (MoE) parallelism analyzer."""
from __future__ import annotations
from dataclasses import asdict, dataclass
import json
import math
from typing import Literal
InterconnectKind = Literal["nvlink_900gbs", "rdma_roce_50gbs", "tcp_ethernet_12_5gbs"]
INTERCONNECT_BANDWIDTH_GBS: dict[InterconnectKind, float] = {
"nvlink_900gbs": 900.0,
"rdma_roce_50gbs": 50.0,
"tcp_ethernet_12_5gbs": 12.5,
}
@dataclass(frozen=True)
class ModelKVSpec:
name: str = "Llama-3-70B-GQA"
layers: int = 80
kv_heads: int = 8
head_dim: int = 128
bytes_per_element: int = 2 # BF16 = 2 bytes, FP8 = 1 byte
def kv_bytes_per_token(self) -> int:
return 2 * self.layers * self.kv_heads * self.head_dim * self.bytes_per_element
def kv_transfer_bytes(self, prompt_tokens: int) -> int:
if prompt_tokens <= 0:
raise ValueError("prompt_tokens must be positive")
return self.kv_bytes_per_token() * prompt_tokens
@dataclass(frozen=True)
class DisaggregationWorkload:
prompt_tokens: int = 4096
decode_tokens: int = 256
target_qps: float = 36.0
prefill_tokens_per_sec_per_gpu: float = 18_500.0
decode_tokens_per_sec_per_gpu: float = 1_600.0
colocated_interference_tpot_penalty_ms: float = 29.5
base_decode_tpot_ms: float = 21.0
def evaluate_pd_disaggregation(
kv_spec: ModelKVSpec = ModelKVSpec(),
workload: DisaggregationWorkload = DisaggregationWorkload(),
interconnect: InterconnectKind = "rdma_roce_50gbs",
) -> dict[str, object]:
"""Compares co-located continuous batching against disaggregated Prefill (P) and Decode (D) GPU pools."""
kv_bytes = kv_spec.kv_transfer_bytes(workload.prompt_tokens)
kv_megabytes = round(kv_bytes / (1024 * 1024), 2)
bw_gbs = INTERCONNECT_BANDWIDTH_GBS[interconnect]
# Scenario assumptions only: these link efficiencies are not measured.
link_efficiency = 0.52 if interconnect == "tcp_ethernet_12_5gbs" else 0.82
effective_bw_bytes_per_ms = (bw_gbs * 1e9 * link_efficiency) / 1000.0
kv_transfer_ms = round(kv_bytes / effective_bw_bytes_per_ms, 2)
prefill_compute_ms = round(
(workload.prompt_tokens / workload.prefill_tokens_per_sec_per_gpu) * 1000.0, 2
)
# In co-located pools, long prefills queue behind mixed decode steps and inject chunk-prefill stalls into TPOT.
colocated_p99_ttft_ms = round(prefill_compute_ms * 1.48 + 95.0, 2)
colocated_p99_tpot_ms = round(
workload.base_decode_tpot_ms + workload.colocated_interference_tpot_penalty_ms,
2,
)
# Scenario assumes partial transfer overlap; no connector was executed.
# unpipelined TCP waits for full prompt completion and pays host bounce-buffer latency.
overlap_factor = 0.35 if interconnect != "tcp_ethernet_12_5gbs" else 1.05
disagg_p99_ttft_ms = round(
prefill_compute_ms + 32.0 + kv_transfer_ms * overlap_factor, 2
)
disagg_p99_tpot_ms = round(workload.base_decode_tpot_ms, 2)
# Size dedicated P and D GPU pools with 25% headroom.
prefill_token_rate = workload.target_qps * workload.prompt_tokens
decode_token_rate = workload.target_qps * workload.decode_tokens
prefill_gpus = math.ceil(
(prefill_token_rate / workload.prefill_tokens_per_sec_per_gpu) * 1.25
)
decode_gpus = math.ceil(
(decode_token_rate / workload.decode_tokens_per_sec_per_gpu) * 1.25
)
colocated_gpus = math.ceil((prefill_gpus + decode_gpus) * 1.22)
return {
"interconnect": interconnect,
"interconnect_bandwidth_gbs": bw_gbs,
"prompt_tokens": workload.prompt_tokens,
"decode_tokens": workload.decode_tokens,
"kv_bytes_per_token": kv_spec.kv_bytes_per_token(),
"kv_transfer_mib": kv_megabytes,
"raw_kv_transfer_ms": kv_transfer_ms,
"prefill_compute_ms": prefill_compute_ms,
"colocated": {
"total_gpus": colocated_gpus,
"p99_ttft_ms": colocated_p99_ttft_ms,
"p99_tpot_ms": colocated_p99_tpot_ms,
},
"disaggregated": {
"prefill_gpus": prefill_gpus,
"decode_gpus": decode_gpus,
"total_gpus": prefill_gpus + decode_gpus,
"pd_ratio": f"{prefill_gpus}:{decode_gpus}",
"p99_ttft_ms": disagg_p99_ttft_ms,
"p99_tpot_ms": disagg_p99_tpot_ms,
"ttft_improved_vs_colocated": disagg_p99_ttft_ms < colocated_p99_ttft_ms,
"tpot_reduction_ms": round(colocated_p99_tpot_ms - disagg_p99_tpot_ms, 2),
},
}
@dataclass(frozen=True)
class MoEArchitectureSpec:
name: str = "hypothetical-moe-scenario"
total_params_b: float = 671.0
active_params_b: float = 37.0
layers: int = 61
routed_experts: int = 64
top_k: int = 8
hidden_dim: int = 7168
expert_intermediate_dim: int = 2048
bytes_per_weight: int = 1 # FP8 weights
def evaluate_moe_parallelism(
moe: MoEArchitectureSpec = MoEArchitectureSpec(),
*,
num_gpus: int = 16,
batch_tokens: int = 512,
strategy: Literal["pure_tp16", "hybrid_tp2_ep8"] = "hybrid_tp2_ep8",
hot_expert_skew_factor: float = 1.15,
enable_redundant_hot_experts: bool = True,
) -> dict[str, object]:
"""Compares Tensor Parallelism (TP) against hybrid TP + Expert Parallelism (EP) for large MoE decode."""
if num_gpus <= 0 or batch_tokens <= 0:
raise ValueError("num_gpus and batch_tokens must be positive")
if hot_expert_skew_factor < 1.0:
raise ValueError("hot_expert_skew_factor must be >= 1.0")
expert_weight_bytes_per_layer = (
moe.routed_experts
* 3
* moe.hidden_dim
* moe.expert_intermediate_dim
* moe.bytes_per_weight
)
hbm_bw_bytes_per_ms = (
3200.0 * 1e9 * 0.72
) / 1000.0 # H100-class effective HBM bandwidth
if strategy == "pure_tp16":
# Pure TP16 slices every expert into 16 tiny shards and executes 2 all-reduce barriers per layer across 16 GPUs.
per_gpu_weight_read = expert_weight_bytes_per_layer / num_gpus
compute_and_hbm_ms_per_layer = (
per_gpu_weight_read / hbm_bw_bytes_per_ms
) / 0.42
comm_ms_per_layer = 0.24 + (batch_tokens * moe.hidden_dim * 2 * 2) / (
50.0 * 1e9 / 1000.0
)
straggler_multiplier = 1.0
layer_tpot_ms = compute_and_hbm_ms_per_layer + comm_ms_per_layer
else:
# Generic hybrid TP/EP placement scenario; experts per rank depend on this input.
# replacing 16-way all-reduce with compact all-to-all token dispatch + combine.
ep_degree = 8
active_fraction = min(1.0, (batch_tokens * moe.top_k) / moe.routed_experts)
per_gpu_weight_read = (expert_weight_bytes_per_layer / ep_degree) * min(
1.0, active_fraction
)
compute_and_hbm_ms_per_layer = (
per_gpu_weight_read / hbm_bw_bytes_per_ms
) / 0.86
routed_bytes = batch_tokens * moe.top_k * moe.hidden_dim * 1 # FP8 dispatch
comm_ms_per_layer = 0.07 + (2 * routed_bytes) / (150.0 * 1e9 / 1000.0)
# If one expert rank receives skewed traffic without redundant hot-expert replication,
# both expert GEMM time and incast all-to-all combine stall on the hottest EP rank.
straggler_multiplier = (
min(1.18, 1.0 + (hot_expert_skew_factor - 1.0) * 0.12)
if enable_redundant_hot_experts
else hot_expert_skew_factor
)
layer_tpot_ms = (
compute_and_hbm_ms_per_layer + comm_ms_per_layer
) * straggler_multiplier
step_tpot_ms = round(layer_tpot_ms * moe.layers, 2)
throughput_tokens_per_sec = round((batch_tokens / step_tpot_ms) * 1000.0, 1)
return {
"model": moe.name,
"strategy": strategy,
"num_gpus": num_gpus,
"batch_tokens": batch_tokens,
"hot_expert_skew_factor": hot_expert_skew_factor,
"enable_redundant_hot_experts": enable_redundant_hot_experts,
"effective_straggler_multiplier": round(straggler_multiplier, 3),
"compute_and_hbm_ms_per_layer": round(
compute_and_hbm_ms_per_layer * straggler_multiplier, 4
),
"comm_ms_per_layer": round(comm_ms_per_layer * straggler_multiplier, 4),
"step_tpot_ms": step_tpot_ms,
"cluster_decode_tokens_per_sec": throughput_tokens_per_sec,
}
def run_all_evaluations() -> dict[str, object]:
kv_spec = ModelKVSpec()
workload = DisaggregationWorkload()
pd_by_network = {
net: evaluate_pd_disaggregation(kv_spec, workload, net)
for net in ("nvlink_900gbs", "rdma_roce_50gbs", "tcp_ethernet_12_5gbs")
}
moe_comparisons = {
"pure_tp16_baseline": evaluate_moe_parallelism(strategy="pure_tp16"),
"hybrid_tp2_ep8_balanced": evaluate_moe_parallelism(
strategy="hybrid_tp2_ep8",
hot_expert_skew_factor=1.15,
enable_redundant_hot_experts=True,
),
"hybrid_tp2_ep8_skewed_no_redundancy": evaluate_moe_parallelism(
strategy="hybrid_tp2_ep8",
hot_expert_skew_factor=2.6,
enable_redundant_hot_experts=False,
),
"hybrid_tp2_ep8_skewed_with_redundancy": evaluate_moe_parallelism(
strategy="hybrid_tp2_ep8",
hot_expert_skew_factor=2.6,
enable_redundant_hot_experts=True,
),
}
return {
"experiment": "prefill-decode-disaggregation-and-moe-expert-parallelism",
"kv_spec": asdict(kv_spec),
"pd_disaggregation_by_interconnect": pd_by_network,
"moe_parallelism": moe_comparisons,
}
def main() -> None:
print(
json.dumps(
{
"execution_mode": "CPU algorithm with controlled inputs; any latency/capacity coefficients are assumptions",
**run_all_evaluations(),
},
indent=2,
)
)
if __name__ == "__main__":
main()Transfer time grows with the payload and falls with effective bandwidth. Any overlap term is an assumption that needs measurement in the selected connector. The MoE scenario is independent of this dense-model KV payload calculation.
Inspect the complete local result
{
"execution_mode": "CPU algorithm with controlled inputs; any latency/capacity coefficients are assumptions",
"experiment": "prefill-decode-disaggregation-and-moe-expert-parallelism",
"kv_spec": {
"name": "Llama-3-70B-GQA",
"layers": 80,
"kv_heads": 8,
"head_dim": 128,
"bytes_per_element": 2
},
"pd_disaggregation_by_interconnect": {
"nvlink_900gbs": {
"interconnect": "nvlink_900gbs",
"interconnect_bandwidth_gbs": 900.0,
"prompt_tokens": 4096,
"decode_tokens": 256,
"kv_bytes_per_token": 327680,
"kv_transfer_mib": 1280.0,
"raw_kv_transfer_ms": 1.82,
"prefill_compute_ms": 221.41,
"colocated": {
"total_gpus": 22,
"p99_ttft_ms": 422.69,
"p99_tpot_ms": 50.5
},
"disaggregated": {
"prefill_gpus": 10,
"decode_gpus": 8,
"total_gpus": 18,
"pd_ratio": "10:8",
"p99_ttft_ms": 254.05,
"p99_tpot_ms": 21.0,
"ttft_improved_vs_colocated": true,
"tpot_reduction_ms": 29.5
}
},
"rdma_roce_50gbs": {
"interconnect": "rdma_roce_50gbs",
"interconnect_bandwidth_gbs": 50.0,
"prompt_tokens": 4096,
"decode_tokens": 256,
"kv_bytes_per_token": 327680,
"kv_transfer_mib": 1280.0,
"raw_kv_transfer_ms": 32.74,
"prefill_compute_ms": 221.41,
"colocated": {
"total_gpus": 22,
"p99_ttft_ms": 422.69,
"p99_tpot_ms": 50.5
},
"disaggregated": {
"prefill_gpus": 10,
"decode_gpus": 8,
"total_gpus": 18,
"pd_ratio": "10:8",
"p99_ttft_ms": 264.87,
"p99_tpot_ms": 21.0,
"ttft_improved_vs_colocated": true,
"tpot_reduction_ms": 29.5
}
},
"tcp_ethernet_12_5gbs": {
"interconnect": "tcp_ethernet_12_5gbs",
"interconnect_bandwidth_gbs": 12.5,
"prompt_tokens": 4096,
"decode_tokens": 256,
"kv_bytes_per_token": 327680,
"kv_transfer_mib": 1280.0,
"raw_kv_transfer_ms": 206.49,
"prefill_compute_ms": 221.41,
"colocated": {
"total_gpus": 22,
"p99_ttft_ms": 422.69,
"p99_tpot_ms": 50.5
},
"disaggregated": {
"prefill_gpus": 10,
"decode_gpus": 8,
"total_gpus": 18,
"pd_ratio": "10:8",
"p99_ttft_ms": 470.22,
"p99_tpot_ms": 21.0,
"ttft_improved_vs_colocated": false,
"tpot_reduction_ms": 29.5
}
}
},
"moe_parallelism": {
"pure_tp16_baseline": {
"model": "hypothetical-moe-scenario",
"strategy": "pure_tp16",
"num_gpus": 16,
"batch_tokens": 512,
"hot_expert_skew_factor": 1.15,
"enable_redundant_hot_experts": true,
"effective_straggler_multiplier": 1.0,
"compute_and_hbm_ms_per_layer": 0.182,
"comm_ms_per_layer": 0.5336,
"step_tpot_ms": 43.65,
"cluster_decode_tokens_per_sec": 11729.7
},
"hybrid_tp2_ep8_balanced": {
"model": "hypothetical-moe-scenario",
"strategy": "hybrid_tp2_ep8",
"num_gpus": 16,
"batch_tokens": 512,
"hot_expert_skew_factor": 1.15,
"enable_redundant_hot_experts": true,
"effective_straggler_multiplier": 1.018,
"compute_and_hbm_ms_per_layer": 0.181,
"comm_ms_per_layer": 0.4698,
"step_tpot_ms": 39.7,
"cluster_decode_tokens_per_sec": 12896.7
},
"hybrid_tp2_ep8_skewed_no_redundancy": {
"model": "hypothetical-moe-scenario",
"strategy": "hybrid_tp2_ep8",
"num_gpus": 16,
"batch_tokens": 512,
"hot_expert_skew_factor": 2.6,
"enable_redundant_hot_experts": false,
"effective_straggler_multiplier": 2.6,
"compute_and_hbm_ms_per_layer": 0.4623,
"comm_ms_per_layer": 1.1998,
"step_tpot_ms": 101.39,
"cluster_decode_tokens_per_sec": 5049.8
},
"hybrid_tp2_ep8_skewed_with_redundancy": {
"model": "hypothetical-moe-scenario",
"strategy": "hybrid_tp2_ep8",
"num_gpus": 16,
"batch_tokens": 512,
"hot_expert_skew_factor": 2.6,
"enable_redundant_hot_experts": true,
"effective_straggler_multiplier": 1.18,
"compute_and_hbm_ms_per_layer": 0.2098,
"comm_ms_per_layer": 0.5445,
"step_tpot_ms": 46.02,
"cluster_decode_tokens_per_sec": 11125.6
}
}
}
This image is an application-generated report of the saved program result, not a new inference run. Open the full-resolution report or read the raw result.
Account for network and expert imbalance
Separating pools can help control phase-specific latency, but adds network work, routing, failure recovery, and capacity coordination. It is not a general throughput improvement. Overlap may hide only part of transfer time. MoE expert placement and replication add another independent tradeoff.
Transfer bottleneck. Lower effective bandwidth makes the cache handoff slower in the scenario model. The scheduling benefit can disappear; overlap and link efficiency are assumptions.
Expert imbalance. Uneven expert assignment creates a straggler in the generic MoE scenario. The calculation does not execute a real MoE model or validate a balancing implementation.
Read the executable checks
"""Unit and failure-mode tests for disaggregated prefill/decode pools and large MoE serving."""
from __future__ import annotations
import unittest
from lab import (
DisaggregationWorkload,
ModelKVSpec,
evaluate_moe_parallelism,
evaluate_pd_disaggregation,
)
class DisaggregationAndMoETest(unittest.TestCase):
def test_exact_kv_cache_transfer_bytes_and_rdma_pd_disaggregation(self) -> None:
spec = ModelKVSpec(layers=80, kv_heads=8, head_dim=128, bytes_per_element=2)
# 2 (K+V) * 80 * 8 * 128 * 2 = 327,680 bytes/token; for 4,096 tokens = 1,342,177,280 bytes (1,280 MB).
self.assertEqual(spec.kv_bytes_per_token(), 327_680)
self.assertEqual(spec.kv_transfer_bytes(4096), 1_342_177_280)
rdma = evaluate_pd_disaggregation(
spec, DisaggregationWorkload(), "rdma_roce_50gbs"
)
self.assertEqual(rdma["kv_transfer_mib"], 1280.0)
self.assertTrue(rdma["disaggregated"]["ttft_improved_vs_colocated"])
self.assertLess(
rdma["disaggregated"]["p99_tpot_ms"],
rdma["colocated"]["p99_tpot_ms"],
)
def test_slow_network_kv_transfer_erases_disaggregation_ttft_gain(self) -> None:
spec = ModelKVSpec()
workload = DisaggregationWorkload(prompt_tokens=4096)
rdma = evaluate_pd_disaggregation(spec, workload, "rdma_roce_50gbs")
tcp = evaluate_pd_disaggregation(spec, workload, "tcp_ethernet_12_5gbs")
# Over 12.5 GB/s TCP without RDMA pipelining, transferring 1.28 GB of KV cache takes >120ms,
# pushing disaggregated TTFT above the co-located baseline.
self.assertTrue(rdma["disaggregated"]["ttft_improved_vs_colocated"])
self.assertFalse(tcp["disaggregated"]["ttft_improved_vs_colocated"])
self.assertGreater(
tcp["disaggregated"]["p99_ttft_ms"],
tcp["colocated"]["p99_ttft_ms"],
)
def test_hybrid_tp_ep_outperforms_pure_tp16_on_large_moe(self) -> None:
pure_tp = evaluate_moe_parallelism(strategy="pure_tp16")
hybrid_ep = evaluate_moe_parallelism(strategy="hybrid_tp2_ep8")
self.assertLess(hybrid_ep["step_tpot_ms"], pure_tp["step_tpot_ms"])
self.assertGreater(
hybrid_ep["cluster_decode_tokens_per_sec"],
pure_tp["cluster_decode_tokens_per_sec"],
)
def test_skewed_expert_routing_creates_ep_straggler_bottleneck(self) -> None:
unmitigated = evaluate_moe_parallelism(
strategy="hybrid_tp2_ep8",
hot_expert_skew_factor=2.6,
enable_redundant_hot_experts=False,
)
mitigated = evaluate_moe_parallelism(
strategy="hybrid_tp2_ep8",
hot_expert_skew_factor=2.6,
enable_redundant_hot_experts=True,
)
# Without redundant hot-expert replication, 2.6x routing skew inflates step TPOT by >70%.
self.assertGreater(unmitigated["step_tpot_ms"], mitigated["step_tpot_ms"] * 1.5)
self.assertGreater(
mitigated["cluster_decode_tokens_per_sec"],
unmitigated["cluster_decode_tokens_per_sec"] * 1.5,
)
if __name__ == "__main__":
unittest.main()
class GuardBoundaryTests(unittest.TestCase):
def test_rejects_invalid_boundary_inputs_with_specific_reason(self):
import lab
cases = [('evaluate_moe_parallelism(num_gpus=0)', 'num_gpus'), ('evaluate_moe_parallelism(hot_expert_skew_factor=0.5)', 'skew_factor'), ('ModelKVSpec().kv_transfer_bytes(0)', 'prompt_tokens')]
for code, reason in cases:
with self.subTest(code=code):
with self.assertRaisesRegex((ValueError, MemoryError, KeyError, RuntimeError), reason):
exec(code, vars(lab).copy())Try a change and explain the result
Hold the prompt constant and lower effective transfer bandwidth. Find when handoff erases the assumed scheduling benefit. Then explain why a hot expert can limit the MoE path even when total cluster memory is sufficient.
Read the explanation
KV transfer is an explicit dependency. Layout, dtype, configuration, and handoff cost must be validated.
Test the runtime connector before splitting pools
Start with a working colocated baseline. Validate the exact runtime connector, cache dtype/layout, model revision, and cancellation behavior. Measure end-to-end latency and effective transfer throughput under load before choosing pool sizes.
References
- vLLM disaggregated prefill boundaries.
- SGLang prefill/decode disaggregation.
- vLLM expert parallel deployment.
Sources checked October 24, 2026. Runtime-specific commands and results apply to the recorded versions; linked rolling documentation can change. The application scenarios are teaching examples, not claims about an author’s production incident.