P/D Disaggregation
Overviewβ
This guide splits inference into separate prefill and decode pools. Prefill pods process the prompt and produce its KV cache; decode pods pull that KV cache over a KV transfer connector (NIXL by default) and generate the output tokens. Both pools belong to one InferencePool, and the llm-d Router schedules every request twice: once onto a prefill pod and once onto a decode pod. Because disaggregation is built into the router, it composes with its other scorers:
- Prefill β the
prefix-cache-affinity-filterkeeps prefix groups on cache-warm prefill pods (gated by a calibratedpeakPrefillThroughput), and thetoken-load-scorerpicks the prefill pod with the least queued prompt work. - Decode β the
active-request-scorerpicks the decode pod with the fewest in-flight requests, since pure decode is bound by concurrency rather than prompt throughput.
The default deployment serves openai/gpt-oss-120b on NVIDIA GPUs with 1 prefill pod (TP=1) and 1 decode pod (TP=4), 5 GPUs in total: the smallest topology that exercises the full P/D path. Production deployments scale the two pools independently (see When to use P/D and how to tune it).
How the two pools are deployed depends on the accelerator:
- All accelerators: one
DisaggregatedSet(pd-disagg-vllmorpd-disagg-sglangon NVIDIA GPU,pd-disagg-tpu-vllmon TPU7x dynamic sub-slices,pd-disagg-<accelerator>elsewhere) with aprefilland adecoderole, in a single slice. The set rolls both roles out as one version and can replicate the whole topology into independent copies (slices); it requires the LeaderWorkerSet controller (see Prerequisites) and is covered in Operating the DisaggregatedSet.
For why P/D disaggregation helps, how requests flow between the two pools, and tuning guidance, see Disaggregated Serving.
Supported Accelerators and Model Serversβ
This guide includes configurations for the following accelerator and model server combinations (set ACCELERATOR_TYPE and MODEL_SERVER accordingly). Each accelerator serves one model; its platform overlays are selected with INFRA_PROVIDER (see Configure the environment):
| Accelerator | ACCELERATOR_TYPE | Served model | vLLM | SGLang | Notes |
|---|---|---|---|---|---|
| NVIDIA GPU | gpu | openai/gpt-oss-120b | β validated | β validated | H200 Β· 1P (TP=1) + 1D (TP=4), 5 GPUs; SGLang 1P (TP=2) + 1D (TP=2) Β· NIXL |
| AMD GPU | amd | openai/gpt-oss-120b | β validated | β | AMD Instinct Β· 1P (TP=1) + 1D (TP=4), 5 GPUs Β· NIXL |
| Intel XPU | xpu | Qwen/Qwen3-0.6B | β validated | β | Intel GPU via DRA Β· 1P + 1D, 1 GPU each Β· NIXL |
| Google TPU v6e | tpu/v6 | Qwen/Qwen3-32B | β validated | β | 2x4 v6e nodes on GKE Β· 1P + 1D, 8 chips each (TP=8) Β· TPUConnector |
| Google TPU v7 | tpu/v7 | Qwen/Qwen3.5-397B-A17B-FP8 | π‘ community | β | 2x2x1 TPU7x nodes on GKE Β· 1P + 1D, 4 chips each (TP=8) Β· TPUConnectorHMA |
| Google TPU v7 (dynamic slicing) | tpu/v7-dynamic-slice | Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8 | π‘ community | β | TPU7x sub-slices via dynamic slicing + Kueue Β· DisaggregatedSet with 1P + 1D, one 2x2x1 sub-slice each Β· TPUConnectorHMA |
| Iluvatar GPU | iluvatar | Qwen/Qwen3-32B | π‘ community | β | BI-V150 Β· 1P + 1D, 2 boards each (TP=4) Β· IluNixlConnector |
| MetaX GPU | metax | Qwen/Qwen3-32B | π‘ community | β | C500X Β· 1P (TP=2) + 1D (TP=4) Β· NIXL over TCP |
| Biren GPU | biren | Qwen/Qwen2.5-72B-Instruct-GPTQ-Int8 | π‘ community | β | 166M Β· 1P+1D, each TP=4 Β· NixlConnector over RDMA (UCX_TLS=rc_v) |
| Moore Threads GPU | mthreads | Qwen/Qwen3-32B | π‘ community | π‘ community | MTT S5000 Β· vLLM 1P TP=4 + 1D TP=4, Mooncake TCP; SGLang 1P+1D TP=8, Mooncake RDMA |
| Rebellions NPU | npu | MiniMaxAI/MiniMax-M2.7 | π‘ community | β | 4 NPUs + 1 RoCE VF per pod via DRA Β· 1P (PP=4) + 1D (DP=4, EP) Β· RblnNixlConnector |
β validated: covered by a nightly E2E workflow Β· π‘ community: maintained by the hardware vendor or community, not covered by nightly E2E Β· β not supported: tracked in the linked issue Β· β no configuration.
Community rows are compatibility configurations maintained by their hardware vendors, often with reduced sizing (smaller models, fewer accelerators); review and adjust them for your environment before production use.
KV Transfer Connectorsβ
P/D disaggregation requires a KV transfer connector to move KV cache blocks from prefill workers to decode workers. On vLLM it is configured with the --kv-transfer-config flag:
| Connector | Overlays | Transport | Notes |
|---|---|---|---|
| NixlConnector | default on NVIDIA GPU, AMD GPU, Intel XPU and MetaX | UCX (RDMA / TCP) | Supports heterogeneous TP across P/D. |
| RblnNixlConnector | npu/vllm/base | NIXL over a RoCE VF | Rebellions' NIXL connector with kv_buffer_device=rbln. See the Rebellions NPU tab. |
| MooncakeConnector | gpu/vllm/cks-mooncake | RDMA via Mooncake Transfer Engine | CKS with InfiniBand. See the platform table in Prerequisites. |
| MoRIIOConnector | amd/vllm/moriio/* | RDMA via MoRI-IO | AMD GPU. |
| TPUConnector / TPUConnectorHMA | tpu/* | TPU ICI / DCN | From tpu_inference; HMA on TPU7x. |
| IluNixlConnector | iluvatar/vllm/base | UCX with CUDA-aware transports | Iluvatar's fork of NixlConnector. See the Iluvatar tab. |
| NixlConnector | biren/vllm/base | RDMA (UCX_TLS=rc_v) | Biren 166M, kv_buffer_device=cpu. See the Biren tab. |
| MooncakeConnector | mthreads/vllm/base | TCP via Mooncake MUSA transport | MTT S5000 compatibility path; requires the same TP on prefill and decode. See the Moore Threads tab. |
| SGLang mooncake | mthreads/sglang/base | RDMA via Mooncake Transfer Engine | SGLang --disaggregation-transfer-backend mooncake (not vLLM MooncakeConnector). Same TP on prefill and decode. |
NIXL works over TCP, but RDMA networking (InfiniBand, RoCE, EFA) is highly recommended for production.
vLLM vs SGLangβ
Both engines run on NVIDIA GPU with the same router configuration and move KV cache over NIXL, but they disaggregate differently:
| Aspect | vLLM | SGLang |
|---|---|---|
| P/D decider | Can disaggregate conditionally (prefix-based-pd-decider); this guide's router uses always-disagg-pd-decider | Must use always-disagg-pd-decider: a decode worker has no local-prefill path. See Unconditional Disaggregation |
| Peer discovery | NIXL side-channel handshake (TCP 5600) | Prefill bootstrap server on port 8998. To change it, set SGLANG_BOOTSTRAP_PORT on the sidecar and --disaggregation-bootstrap-port on the engine so the two match |
| Request cancellation | Prefill-side free notification releases the KV cache | No prefill-side free notification or reclaim timeout: a request cancelled before decode pulls the KV cache can strand it on the prefill until the pod restarts |
| Engine flags | --kv-transfer-config (see above) | --disaggregation-mode={prefill,decode}, --disaggregation-transfer-backend=nixl; the decode routing sidecar runs with --kv-connector=sglang |
| Operations | Disaggregated Serving: Operations (vLLM) | Disaggregated Serving: Operations (SGLang) |
Prerequisitesβ
-
Have the proper client tools installed on your local system to use this guide.
-
Ensure your cluster has enough accelerators for your configuration (default NVIDIA GPU configuration: 1 prefill pod with tensor parallelism 1 and 1 decode pod with tensor parallelism 4, 5 GPUs in total). The prefill and decode pods exchange KV cache over the network, so place them on nodes connected by a high-bandwidth fabric.
-
Create a HuggingFace token and export it as
HF_TOKENin your shell. -
(Optional) Set up the monitoring stack if you plan to enable Prometheus monitoring.
-
The LeaderWorkerSet controller
v0.11.1or newer with theDisaggregatedSetAPI enabled (--set enableDisaggregatedSet=truewhen installing with Helm, which also installs its validating webhook and RBAC). The environment step below installs it; skip that step if your cluster already runs it. For TPU7x dynamic sub-slices, install or upgrade it through the GKE dynamic slicing cluster preparation instead, whose GKE-documented minimum LWS version is older. -
Prepare the cluster for your platform (
INFRA_PROVIDER).base,nixl/base,moriio/base, and the TPU and community accelerators need nothing beyond the accelerator's device plugin (see the model server tabs); the other platforms need:Accelerator INFRA_PROVIDERCluster requirements Setup NVIDIA GPU gke,gke/a4x,gke/a4xmaxGPU DRA (manual node labels and driver install, not yet GKE-managed) and managed DRANET for RoCE; gketargets A3/A4,gke/a4x/gke/a4xmaxtarget A4X / A4X Max (GB200 / GB300); DRANet must support hairpin (same-node) and cross-rail (inter-node multi-rail) routingGKE: GPU DRA and DRANET, GKE A4X setup coreweaveAn RDMA device plugin exposing rdma/ibinside pods (one per pod); a default StorageClass supportingReadWriteManyfor the model cache PVCCoreWeave, RDMA resources cks-mooncakevLLM only. GPUs via nvidia.com/gpu; InfiniBand devices exposed inside pods via anrdma/ibdevice plugin (NVIDIA Network Operator, Multus with SR-IOV, or equivalent); a vLLM image withmooncake-transfer-engine(the standard image may not include it);VLLM_MOONCAKE_BOOTSTRAP_PORT(default8998) unique per co-located instance. ConfiguresMooncakeConnectortransport only, not Mooncake StoreRDMA resources, verify RDMA in a pod, Mooncake installation awsp5en.48xlargenodes with the EFA device plugin exposingvpc.amazonaws.com/efaAWS EFA notes AMD GPU nixl/ociOKE BM.GPU.MI300X.8nodes; NVIDIA Network Operator SR-IOV VFs asnvidia.com/sriov-rdma-vf, with asriov-rdma-vfSriovNetwork in namespacedefaultRDMA resources nixl/tensorwaveAn RDMA device plugin exposing rdma/ibinside podsRDMA resources Intel XPU rdmaA network DRA driver publishing a dranet-rdmaDeviceClass with RDMA-capable NICs (dra.netrdmaattribute); each claim aligns the NIC with its GPU's PCIe rootRDMA resources
Get the guideβ
Every command below runs from a local clone of the llm-d repository: the manifests, Helm values, and Kustomize overlays it applies live next to this guide. Set the branch and clone the repo (if you already have a checkout, skip this and run the remaining commands from inside it):
export BRANCH=main
git clone https://github.com/llm-d/llm-d.git && cd llm-d && git checkout ${BRANCH}
Configure the environmentβ
INFRA_PROVIDER selects the platform overlay for your ACCELERATOR_TYPE and, where an accelerator offers more than one, the KV transfer connector. Valid values per accelerator:
ACCELERATOR_TYPE | INFRA_PROVIDER values |
|---|---|
gpu | base, gke, gke/a4x, gke/a4xmax, coreweave, aws, cks-mooncake (SGLang: base, gke, coreweave, aws) |
amd | nixl/base, nixl/oci, nixl/tensorwave, moriio/base |
xpu | base, rdma |
tpu/v6, tpu/v7, tpu/v7-dynamic-slice | gke |
iluvatar, metax, biren, mthreads, npu | base |
base: generic Kubernetes cluster.gke: on NVIDIA GPU, GKE A3/A4 with GPU DRA and DRANet RoCE (see the platform table in Prerequisites);gke/a4xandgke/a4xmaxtarget GKE A4X / A4X Max (GB200 / GB300). On TPU, the GKE TPU node pools described in the model server step.coreweave: CoreWeave withrdma/ib.aws: AWS with EFA.cks-mooncake: CoreWeave CKS withMooncakeConnectorover InfiniBand, vLLM only (see the platform table in Prerequisites).nixl/*:NixlConnectoron AMD;nixl/ocitargets OCI OKEBM.GPU.MI300X.8with SR-IOV RDMA VFs, andnixl/tensorwavetargets TensorWave.moriio/base:MoRIIOConnectorinstead of NIXL, servingQwen/Qwen3-32B(setMODELaccordingly).rdma(Intel XPU): NIXL over RDMA instead of TCP.
Set the guide-specific environment variables:
export REPO_ROOT=$(realpath $(git rev-parse --show-toplevel))
export GUIDE_NAME=pd-disaggregation
export NAMESPACE=llm-d-pd-disaggregation
export MONITORING=false # options: false, true
export MONITORING_VALUES=
export ACCELERATOR_VALUES=
export ACCELERATOR_TYPE=gpu # options: gpu, amd, xpu, tpu/v6, tpu/v7, tpu/v7-dynamic-slice, iluvatar, metax, biren, mthreads, npu
export MODEL_SERVER=vllm # options: vllm, sglang
export INFRA_PROVIDER=base # options: base, gke, gke/a4x, gke/a4xmax, coreweave, aws, cks-mooncake, nixl/base, nixl/amd-ci, nixl/oci, nixl/tensorwave, moriio/base, moriio/amd-ci, moriio/amd-ci-1p1d-tp8, rdma; valid values per accelerator: table above
export MODEL=openai/gpt-oss-120b # set to the model your accelerator serves (Supported Accelerators and Model Servers table); the AMD MoRIIO overlays (INFRA_PROVIDER=moriio/*) serve Qwen/Qwen3-32B
source ${REPO_ROOT}/guides/env.sh # defines GAIE_VERSION, ROUTER_CHART_VERSION, router chart URLs, and CURL_TEST_IMAGE
Install the LeaderWorkerSet controller with the DisaggregatedSet API (skip if your cluster already runs LWS v0.11.1 or newer with enableDisaggregatedSet=true):
# helm upgrade never updates CRDs, so apply the chart's CRDs first (see LWS
# "Upgrade by Helm"); this upgrades them on clusters that run an older LWS.
helm pull oci://registry.k8s.io/lws/charts/lws --version=${LWS_VERSION} --untar
kubectl apply --server-side --force-conflicts -f lws/crds
helm upgrade --install lws oci://registry.k8s.io/lws/charts/lws \
--version=${LWS_VERSION} \
--namespace lws-system --create-namespace \
--set enableDisaggregatedSet=true \
--wait --timeout 300s
Install the Gateway API Inference Extension CRDs:
# GAIE_URL is automatically calculated from GAIE_VERSION at ${REPO_ROOT}/guides/env.sh
kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/${GAIE_URL}/v1-manifests.yaml
Create a target namespace for the installation:
kubectl create namespace ${NAMESPACE} --dry-run=client -o yaml | kubectl apply -f -
On CoreWeave, create the model cache PVC before applying the model server overlay, which mounts the HuggingFace cache from an RWX PVC named model-pvc rather than a node-local hostPath. See Caching model weights on a shared PVC for why, and for the cold-cache caveat with multiple replicas.
# only when INFRA_PROVIDER=coreweave:
kubectl apply -n ${NAMESPACE} -f ${REPO_ROOT}/guides/recipes/modelserver/components/model-cache/model-cache-pvc.yaml
Create the llm-d-hf-token secret in your target namespace with the key HF_TOKEN matching a valid HuggingFace token to pull models:
kubectl create secret generic llm-d-hf-token \
--from-literal="HF_TOKEN=${HF_TOKEN}" \
--namespace "${NAMESPACE}" \
--dry-run=client -o yaml | kubectl apply -f -
Installation Instructionsβ
1. Deploy the llm-d Routerβ
Prepare the paths to the helm values files for the llm-d router (used in the deployment command below):
# Paths to values files
export ROUTER_BASE_VALUES="${REPO_ROOT}/guides/recipes/router/base.values.yaml"
export ROUTER_VALUES="${REPO_ROOT}/guides/${GUIDE_NAME}/router/${GUIDE_NAME}.values.yaml"
The router configuration in router/pd-disaggregation.values.yaml disaggregates every request (always-disagg-pd-decider) and schedules it with a prefill and a decode profile, which select pods by their llm-d.ai/role label.
The prefix-cache-affinity-filter uses a peakPrefillThroughput of 33821, calibrated for gpt-oss-120b on TP=1 H200 prefill workers through the full P/D path (including the NIXL KV transfer). On a different model or accelerator, measure and set your value with the calibration guide before performance work.
(Optional) Enable Prometheus monitoring on the llm-d router by defining MONITORING_VALUES (see Collect Metrics):
- OSS Prometheus / OpenShift
- GKE (Managed Prometheus)
# only when MONITORING=true:
export MONITORING_VALUES="-f ${REPO_ROOT}/guides/recipes/router/features/monitoring.values.yaml"
# only when MONITORING=true and INFRA_PROVIDER=gke:
export MONITORING_VALUES="-f ${REPO_ROOT}/guides/recipes/router/features/monitoring-gke.values.yaml"
(Rebellions NPU only) Layer the NPU router values, which list every decode rank port:
export ACCELERATOR_VALUES="-f ${REPO_ROOT}/guides/${GUIDE_NAME}/router/npu.rbln.values.yaml"
Deploy the router in Standalone Mode, with an Envoy sidecar in front of the router. The release name ${GUIDE_NAME} is mandatory: the InferencePool selector matches a guide label that pairs with this release. To front the router with a Kubernetes Gateway instead, see Gateway Mode in the Optimized Baseline.
helm install ${GUIDE_NAME} \
${ROUTER_STANDALONE_CHART} \
-f ${ROUTER_BASE_VALUES} \
${MONITORING_VALUES} \
-f ${ROUTER_VALUES} \
${ACCELERATOR_VALUES} \
-n ${NAMESPACE} --version ${ROUTER_CHART_VERSION}
2. Deploy the Model Serverβ
For model sources, caching, and startup optimization, see the Model Loading and Startup Acceleration operations guide.
Apply the Kustomize overlay for your accelerator and model server. Each overlay deploys a prefill role (pods labeled llm-d.ai/role=prefill) and a decode role (llm-d.ai/role=decode, with the routing sidecar in front of the engine): the prefill and decode roles of a DisaggregatedSet on NVIDIA GPU and TPU7x dynamic sub-slices, two Deployments elsewhere. See Configure the environment for the INFRA_PROVIDER values of each accelerator.
- Default (NVIDIA GPU, AMD, Intel XPU)
- Google TPU
- Google TPU v7 (dynamic slicing)
- Iluvatar
- MetaX
- Biren
- Rebellions NPU
- Moore Threads
kubectl apply -n ${NAMESPACE} \
-k ${REPO_ROOT}/guides/${GUIDE_NAME}/modelserver/${ACCELERATOR_TYPE}/${MODEL_SERVER}/${INFRA_PROVIDER}/
The GPU overlays enable tool calling and reasoning parsing with --enable-auto-tool-choice --tool-call-parser=openai --reasoning-parser=openai_gptoss.
kubectl apply -n ${NAMESPACE} \
-k ${REPO_ROOT}/guides/${GUIDE_NAME}/modelserver/${ACCELERATOR_TYPE}/${MODEL_SERVER}/${INFRA_PROVIDER}/
The TPU overlays use the TPUConnector (v6e) or TPUConnectorHMA (TPU7x) KV connector from tpu_inference in place of NixlConnector, and run on GKE only (INFRA_PROVIDER=gke). The NVIDIA GPU gke platform requirements in Prerequisites (GPU DRA / DRANet) do not apply.
Tool calling and reasoning parsing are enabled with the parsers for the model each overlay serves, not the GPU overlays' gpt-oss pair: --tool-call-parser=hermes with --reasoning-parser=qwen3 for Qwen/Qwen3-32B on v6e (the parser Qwen documents for vLLM), and --tool-call-parser=qwen3_xml with --reasoning-parser=qwen3 for Qwen/Qwen3.5-397B-A17B-FP8 on TPU7x, whose chat template emits XML-style rather than Hermes-style tool calls.
Requirements:
- TPU v6e: nodes with
2x4topology (tpu-v6e-slice, 8 chips per node, 1 core per chip). Each prefill and decode pod requests all 8 chips (google.com/tpu: 8). - TPU7x: nodes with
2x2x1topology (tpu7x, 4 chips per node, 2 cores per chip). Each prefill and decode pod requests 4 chips (google.com/tpu: 4). - Boot disk: the TPU7x model is 406 GB on disk; size the TPU node boot disk so that this much space remains free above the kubelet ephemeral-storage eviction threshold, or the pod is evicted during the first download.
Configuration notes:
- Model weights are cached on the node under
/var/cache/huggingface(ahostPathvolume, as in the GKE GPU overlays), so restarts and re-creations of a pod do not download them again.
Known issues:
The TPU7x overlays pin vllm/vllm-tpu:v0.26.0 through the tpu-vllm/release-v0.26.0 image component. In v0.27.0 through v0.29.0 the vLLM scheduler reads connector._kv_transfer_config, which the bundled TPUConnectorHMA never initializes, and EngineCore fails at startup. The fix is tpu-inference#3566; the pin is removed once a vllm-tpu release includes it. The TPU v6e overlay uses the non-HMA TPUConnector and is unaffected.
# One DisaggregatedSet; each role's LeaderWorkerSet group gets a 2x2x1 sub-slice, admitted by Kueue
kubectl apply -n ${NAMESPACE} -f ${REPO_ROOT}/docs/infrastructure/providers/gke/dynamic-slicing/kueue-localqueue.yaml
kubectl apply -n ${NAMESPACE} \
-k ${REPO_ROOT}/guides/${GUIDE_NAME}/modelserver/${ACCELERATOR_TYPE}/${MODEL_SERVER}/${INFRA_PROVIDER}/
ACCELERATOR_TYPE=tpu/v7-dynamic-slice deploys Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8 (TP=8 per 2x2x1 sub-slice) as one DisaggregatedSet (pd-disagg-tpu-vllm) with a prefill and a decode role of one replica each. The controller generates one LeaderWorkerSet per role, and Kueue Topology-Aware Scheduling places each LeaderWorkerSet group on a sub-slice that GKE dynamic slicing forms on demand from pre-provisioned 4x4x4 sub-blocks, instead of a statically provisioned 2x2x1 node pool.
This overlay sets --enable-auto-tool-choice --tool-call-parser=qwen3_xml, the parser vLLM documents for Qwen3-Coder. It sets no reasoning parser: Qwen3-Coder-480B-A35B-Instruct does not emit reasoning blocks.
Requirements:
- Pre-provisioned TPU7x
4x4x4sub-blocks to form the sub-slices from. - The LeaderWorkerSet controller
v0.11.1or newer with theDisaggregatedSetAPI (see Prerequisites); the GKE page's minimum LWS version is older. - Cluster preparation, the cluster-scoped Kueue resources, and the per-pod workload requirements from the GKE dynamic slicing provider page, which also maps each slice shape to the LWS
sizeand maximum TP.
Configuration notes:
- Once those prerequisites are in place and the router is deployed, the command above creates the
LocalQueueand applies the overlay. - Pods are admitted once their
Sliceresources areACTIVE(kubectl get slices -n ${NAMESPACE}). - Adjust
replicasof theprefillanddecoderoles independently for other xPyD ratios, orslicesfor complete copies of the topology; each LeaderWorkerSet group receives its own2x2x1sub-slice. See Operating the DisaggregatedSet. For a worked multi-host (2x2x2) example, see the aggregated dynamic-slice recipes. - Two settings are specific to Kueue admission: the
kueue.x-k8s.io/queue-namelabel is set in each role'smetadata.labels, and the roles keep the defaultgroupIdentity: Ordinalinstead of theHashmode of the NVIDIA GPU overlays. See Kueue-Scheduled Sets for the reasons.
Known issues:
- The dynamic-slice variant is not in the nightly e2e matrix: an end-to-end run requires one full TPU7x
4x4x4sub-block (64 chips, 16tpu7x-standard-4tnodes) in an All Capacity mode reservation, which is not available to llm-d CI. The manifests are validated by kustomize dry-run in CI and were load tested on internal Google Cloud capacity during the dynamic-slicing beta.
kubectl apply -n ${NAMESPACE} \
-k ${REPO_ROOT}/guides/${GUIDE_NAME}/modelserver/${ACCELERATOR_TYPE}/${MODEL_SERVER}/${INFRA_PROVIDER}/
The iluvatar overlay runs vLLM on Iluvatar BI-V150 with IluNixlConnector, Iluvatar's fork of vLLM's NixlConnector, and kv_buffer_device=cuda (KV stays in VRAM).
Requirements:
- The Iluvatar device plugin (ix-device-plugin) exposing
iluvatar.com/gpu. With its defaultsplitboard: false,iluvatar.com/gpucounts boards. - CUDA-aware UCX transports:
UCX_TLS=cuda_copy,cuda_ipc,tcp,self,posix,sysvplusUCX_CUDA_IPC_ENABLE_SAME_PROCESS=y, set by the overlay. Without them UCX misdetects VRAM as host memory and the prefill engine crashes (SIGSEGV) during the KV read.
Configuration notes:
- Each BI-V150 board is dual-die (32Β GiB per die, 64Β GiB per board), and vLLM
--tensor-parallel-sizecounts CUDA devices (2 per board). The overlay requests 2 boards per role (4 CUDA devices, TP=4) and expandsIX_VISIBLE_DEVICESfromixsmi. - Decode sets
VLLM_ENFORCE_CUDA_GRAPH=1so--compilation-config '{"cudagraph_mode":"FULL_DECODE_ONLY","mode":0}'is not overridden to eager. - Prefill keeps
max-model-len/block-sizealigned with decode and does not enableFULL_DECODE_ONLY.
kubectl apply -n ${NAMESPACE} \
-k ${REPO_ROOT}/guides/${GUIDE_NAME}/modelserver/${ACCELERATOR_TYPE}/${MODEL_SERVER}/${INFRA_PROVIDER}/
The metax overlay runs vLLM on MetaX C500X over NixlConnector and the llm-d routing sidecar. It is a compatibility configuration, not a production xPyD sizing example.
Requirements:
- The MetaX device plugin exposing
metax-tech.com/gpu. - The public MetaX vLLM image (
ghcr.io/project-hami/vllm-metax); air-gapped sites can retag it from a private registry. - A pod network that allows PrefillβDecode TCP 5600 (NIXL side channel) in addition to HTTP 8000/8200. There is no RDMA requirement; TCP is enough for functional validation.
Configuration notes:
- UCX on this path is
maca_ipc,maca_copy,tcp. Do not copy NVIDIAcuda_ipc/cuda_copyvalues. kv_load_failure_policy=failmakes a failed KV pull error out instead of decode silently recomputing the prompt (which looks like HTTP 200 without a real P/D transfer).
Known issues:
- Qwen3 chat completions may emit a
<think>channel unless the client setschat_template_kwargs.enable_thinking=false.
kubectl apply -n ${NAMESPACE} \
-k ${REPO_ROOT}/guides/${GUIDE_NAME}/modelserver/${ACCELERATOR_TYPE}/${MODEL_SERVER}/${INFRA_PROVIDER}/
The biren overlay is a 1 Prefill + 1 Decode compatibility configuration: each replica is TP=4 on four Biren 166M GPUs, serving Qwen/Qwen2.5-72B-Instruct-GPTQ-Int8 over NixlConnector (kv_buffer_device=cpu) and the llm-d routing sidecar (nixlv2). Set ACCELERATOR_TYPE=biren, MODEL_SERVER=vllm, INFRA_PROVIDER=base, and MODEL=Qwen/Qwen2.5-72B-Instruct-GPTQ-Int8.
Requirements:
- The Biren device plugin exposing
birentech.com/gpu. Each role requests four GPUs. - An RDMA device plugin exposing
rdma/ibinside pods (edit the resource name in the patches if yours differs, e.g.rdma/hcaorrdma/roce_gdr). Each role requests one. - An image with vLLM and NIXL, compatible with BR166M.
Configuration notes:
- KV transfer uses
NixlConnectorover RDMA withUCX_TLS=rc_v. - Verify with Router
/v1/completionsor/v1/chat/completionsagainstQwen/Qwen2.5-72B-Instruct-GPTQ-Int8. Confirm decode logs show an external KV transfer rather than decode-only recompute.
kubectl apply -n ${NAMESPACE} \
-k ${REPO_ROOT}/guides/${GUIDE_NAME}/modelserver/${ACCELERATOR_TYPE}/${MODEL_SERVER}/${INFRA_PROVIDER}/
The Rebellions configuration serves MiniMax-M2.7 with heterogeneous parallelism across the two roles. Prefill uses pipeline parallelism because it measured faster per prefill chunk than data parallelism on the same four NPUs.
Requirements:
- The RBLN NPU Operator.
- A network DRA driver publishing a
dranetDeviceClass, so each role can claim a RoCE VF alongside its NPUs. The DeviceClass config has to give that VF an IPv4 address (for exampleinterface.dhcp); without one the NIXL side channel cannot bind. - Kubernetes 1.34 or later for the
resource.k8s.io/v1DRA APIs. - Nodes that can place four NPUs and one RoCE VF on a single NUMA node. A pod whose node cannot supply them stays Pending on
0/3 nodes are available: 1 cannot allocate all claims.
Configuration notes:
| Parameter | Prefill | Decode |
|---|---|---|
| Parallelism | Pipeline, 4 stages | Data, 4 ranks, expert parallel on |
| NPUs | 4 | 4 |
| API servers per pod | 1 (port 8000) | 4 (ports 8200-8203, fronted by the sidecar on 8000-8003) |
--num-gpu-blocks-override | 201 | 51 |
--max-num-seqs | 4 | 4 |
- Both roles share
--block-size=4096, automatic prefix caching, and the on-device sampler. Neither sets--max-model-len, since the model already declares its 204800-token context. - Neither sets
--kv-cache-dtypeeither: the runtime acceptsfp8here, but the--num-gpu-blocks-overridecounts above were measured at the backend default, and halving the bytes per block would leave them describing something else. - KV transfer uses
RblnNixlConnector(NIXL) withkv_buffer_device=rbln. Expert parallelism is on for decode only: a pipeline-parallel prefill rank holds one stage, so there is no expert group to split. - Because the decode role runs one API server per data-parallel rank, the router must be given every rank port: with
ACCELERATOR_TYPE=npu, the router step layersrouter/npu.rbln.values.yamlover the guide's own values file. - NUMA alignment: each role claims four NPUs and one RoCE VF, and NIXL moves KV blocks over that VF. The claim constrains all five devices to one NUMA node on
resource.kubernetes.io/numaNode, which both the NPU driver and dranet advertise. Without that constraint the scheduler may pair a NUMA-1 NPU with a NUMA-0 VF: the pods reach ready and the transfer is what fails, withno usable data-plane RoCE NIC on numa=1.
Known issues:
- Neither the DRA allocation nor the NIXL transfer can be checked by a server dry-run against a cluster without these DeviceClasses. Both need a run on the real hardware.
kubectl apply -n ${NAMESPACE} \
-k ${REPO_ROOT}/guides/${GUIDE_NAME}/modelserver/${ACCELERATOR_TYPE}/${MODEL_SERVER}/${INFRA_PROVIDER}/
Both overlays request mthreads.com/gpu and run prefill and decode as one DisaggregatedSet (pd-disagg-mthreads for vLLM, pd-disagg-mthreads-sglang for SGLang). Set ACCELERATOR_TYPE=mthreads and INFRA_PROVIDER=base, then choose MODEL_SERVER.
SGLang (MODEL_SERVER=sglang): 1 Prefill + 1 Decode, each TP=8 (prefill also EP=8), serving DeepSeek-V4-Flash-0731-FP8-mt with --disaggregation-transfer-backend mooncake. It needs two 8-GPU nodes. For a single 8-GPU node, use the optimized-baseline colocated overlay. Set MODEL=/data/models/DeepSeek-V4-Flash-0731-FP8-mt/.
Requirements:
- Moore Threads GPU Operator / device plugin exposing
mthreads.com/gpu, plus RuntimeClassmthreads. - InfiniBand device plugin exposing
rdma/ib(edit the resource name in the patches if yours differs, e.g.rdma/hcaorrdma/roce_gdr).mt_peermemis a node module for GPUDirect RDMA; load it on the node, do not request it as a Pod resource. - Image
registry.mthreads.com/devtech/sglang-dsv4:1.0(air-gapped sites can retag). - Pods get
IPC_LOCKand Unconfined seccomp so Mooncake can pin memory. They are not privileged. - PrefillβDecode reachability on HTTP 8000/8200 and Mooncake bootstrap TCP 8998, plus RDMA between the injected IB devices.
The routing sidecar sets bootstrap_host to the InferencePool Pod IP. Mooncake then uses that address to pick the RDMA path (QP RTR). Requesting rdma/ib, or bind-mounting /dev/infiniband, is not enough if the RoCE GID is not on that IP β the transfer fails with Failed to modify QP to RTR / No such device. Give the Pod an IP on the RoCE NIC (Multus / SR-IOV). hostNetwork is a cluster-level fallback so podIP equals the node IP; do not bake it into this overlay.
vLLM (MODEL_SERVER=vllm): single-node compatibility topology for Qwen3-32B, a prefill role with TP=4 and a decode role with TP=4. Each role requests four mthreads.com/gpu devices, so the topology consumes all eight GPUs on an MTT S5000 node. Keep MODEL=Qwen/Qwen3-32B.
Requirements:
- MThreads Device Controller exposing
mthreads.com/gpu. - The
llm-d-hf-tokenSecret with a validHF_TOKEN. - The MThreads vLLM image configured in
guides/recipes/modelserver/components/images/mthreads-vllm/release/. - A vLLM image that includes the built-in
MooncakeConnectorand themooncake-transfer-engine-musapackage. The validated MThreads image uses Mooncake's TCP protocol with its MUSA transport; NIXL is not required. - Pod-to-pod TCP connectivity for the Mooncake bootstrap endpoint on port
8998, in addition to the Prefill and Decode HTTP ports (8000and8200). The transfer-engine data-plane RPC ports are allocated dynamically by Mooncake.
Configuration notes:
- Prefill:
MooncakeConnector,kv_role=kv_producer,mooncake_protocol=tcp. - Decode:
MooncakeConnector,kv_role=kv_consumer,mooncake_protocol=tcp. - Decode sidecar:
--kv-connector=mooncake,--mooncake-bootstrap-port=8998. - Prefill and Decode use
kv_load_failure_policy=failso a failed KV pull is visible as an inference error instead of silently recomputing the prompt. - The standalone-store settings are a separate RDMA-backed Mooncake Store setup. They are not required for this single-node P2P validation. A successful HTTP response alone is not sufficient: inspect the vLLM logs for
Using MUSA transport, Mooncake bootstrap startup, and successful KV-transfer metrics. guides/pd-disaggregation/router/pd-disaggregation.values.yamlis enough for a functional smoke test, but itspeakPrefillThroughput: 33821is an NVIDIA H200 reference value. Do not use it for MTT S5000 performance claims; measure the MThreads Qwen3-32B TP=4 prefill path with the calibration recipe and override the value.
Treat the vLLM path as a compatibility smoke test until a clean multi-hour soak and hardware-specific calibration have been completed.
3. Enable Monitoring (optional)β
(Optional) Deploy the monitoring resources for model servers (see Collect Metrics):
- OSS Prometheus / OpenShift
- GKE (Managed Prometheus)
# only when MONITORING=true:
kubectl apply -n ${NAMESPACE} -k ${REPO_ROOT}/guides/recipes/modelserver/components/monitoring-pd
No manual PodMonitor deployment is required when GKE automatic application monitoring is enabled; GKE automatically provisions PodMonitoring resources for the model servers.
Operating the DisaggregatedSetβ
The overlays run prefill and decode as one DisaggregatedSet (pd-disagg-vllm or pd-disagg-sglang on NVIDIA GPU, pd-disagg-<accelerator> elsewhere) with groupIdentity: Hash.
This guide ships slices: 1 with one prefill and one decode replica; each role of each slice runs as its own LeaderWorkerSet (of size 1 here, so every pod is a leader).
Raise the per-role replicas to change the xPyD ratio, or slices to add complete, independently rolled copies of the topology.
Scaling, rollouts, placement policy, router slice affinity, and per-role autoscaling are covered in Disaggregated Serving: Operations (DisaggregatedSet).
The TPU7x dynamic sub-slice overlay (ACCELERATOR_TYPE=tpu/v7-dynamic-slice) runs pd-disagg-tpu-vllm the same way, with groupIdentity: Ordinal so that Kueue admits the generated LeaderWorkerSets; see Kueue-Scheduled Sets.
Verificationβ
1. Get the IP of the Proxyβ
export IP=$(kubectl get service ${GUIDE_NAME}-epp -n ${NAMESPACE} -o jsonpath='{.spec.clusterIP}')
2. Send Test Requestsβ
Send a completion request from a temporary pod inside the cluster:
kubectl run curl-test --rm -i --restart=Never \
--image=${CURL_TEST_IMAGE} \
--namespace="${NAMESPACE}" \
--env="IP=${IP}" \
--env="MODEL=${MODEL}" \
-- /bin/sh -c 'curl -sS -X POST "http://${IP}/v1/completions" -H "Content-Type: application/json" -d "{\"model\": \"${MODEL}\", \"prompt\": \"How are you today?\"}"'
3. Verify P/D disaggregationβ
A served request only proves the stack is up: the decode pod's routing sidecar can also run a request end to end on its own. To confirm that requests are actually split, send a few requests with a long prompt, then check that the prefill pod processed them and the decode pod received their KV cache from it.
Send 5 requests with a ~1k-token prompt:
# 5 requests with a ~1k-token prompt: each is prefilled on a prefill
# pod, and its KV cache is transferred to a decode pod
kubectl run pd-test --rm -i --restart=Never \
--image=${CURL_TEST_IMAGE} \
--namespace="${NAMESPACE}" \
--env="IP=${IP}" \
--env="MODEL=${MODEL}" \
-- /bin/sh -c 'P=$(for i in $(seq 1 60); do printf "The prefill pod computes the KV cache and the decode pod pulls it to generate tokens. "; done)
for i in $(seq 1 5); do
curl -sS -o /dev/null -w "request ${i}: HTTP %{http_code}\n" -X POST "http://${IP}/v1/completions" \
-H "Content-Type: application/json" \
-d "{\"model\": \"${MODEL}\", \"prompt\": \"${P} Question ${i}: what is llm-d?\", \"max_tokens\": 16}"
done'
Read the request and KV-transfer counters of the prefill and decode pods:
# Request and KV-transfer counters of the prefill and decode pods, read
# through the Kubernetes API server proxy (no port-forward needed).
# Prefill engines serve on port 8000; decode engines on 8200, behind
# the routing sidecar on 8000.
for role in prefill decode; do
port=8000; [ "${role}" = decode ] && port=8200
for pod in $(kubectl get pods -n ${NAMESPACE} -l llm-d.ai/guide=${GUIDE_NAME},llm-d.ai/role=${role} -o jsonpath='{.items[*].metadata.name}'); do
echo "== ${role}: ${pod}"
kubectl get --raw "/api/v1/namespaces/${NAMESPACE}/pods/${pod}:${port}/proxy/metrics" \
| grep -E '^(vllm:(request_success_total|prompt_tokens_total|external_prefix_cache_hits_total)|sglang:(num_requests_total|prompt_tokens_total))' || true
done
done
On Rebellions NPU the decode pod runs one API server per data-parallel rank (ports 8200-8203); the loop reads rank 0, which serves a share of the requests.
What to expect:
- vLLM
- SGLang
The prefill pods' vllm:request_success_total counts the requests (prefill runs them with max_tokens=1), and the decode pods' vllm:external_prefix_cache_hits_total grows by roughly the prompt length of each request: those tokens were loaded through the KV transfer connector instead of being recomputed.
sglang:num_requests_total and sglang:prompt_tokens_total grow on both the prefill and the decode pods: the prefill pod computes each prompt, and the decode pod receives its KV cache over NIXL.
If the prefill pods count no requests, the router is not disaggregating: check that the pods carry the llm-d.ai/role labels (on DisaggregatedSet paths, also that its LeaderWorkerSets are ready: kubectl get leaderworkerset -n ${NAMESPACE}) and the router logs (kubectl logs -n ${NAMESPACE} deploy/${GUIDE_NAME}-epp). If prefill counts the requests but decode reports no external prefix-cache hits, the KV transfer is failing and decode recomputes the prompt: check the decode pod's logs for connector errors.
(Optional) Read the router's P/D decisions (MONITORING=true only: without the monitoring values the router's metrics endpoint requires authentication). Each request disaggregated by the router counts under decision_type="prefill-decode":
# only when MONITORING=true:
# P/D decisions taken by the router, read through the API server service proxy
kubectl get --raw "/api/v1/namespaces/${NAMESPACE}/services/${GUIDE_NAME}-epp:9090/proxy/metrics" \
| grep -E '^llm_d_epp_disagg_decision_total' || true
Performance benchmarks for this configuration are not part of this guide: they live with the model-specific guides.
Observability & Troubleshootingβ
Once monitoring is enabled, use the signals below to operate P/D disaggregation under sustained load in production or benchmark scenarios (note that the small number of requests in Verification is only a smoke check and does not produce steady-state pool utilization or saturation signals). This section covers the metrics that matter for this path and how to read them; full metric definitions live in the metric reference and ready-to-run queries in the PromQL reference.
In a P/D deployment the prefill and decode pools scale and fail independently, and every decode step depends on a KV transfer from a prefill worker over the configured KV-transfer backend (NIXL, Mooncake, or MoRIIO). Most problems show up as an imbalance between the two pools or as KV-transfer stalls, so watch them as a pair rather than as a single aggregate.
Key metrics for this pathβ
| Signal | Why it matters for P/D | Where to look |
|---|---|---|
Prefill worker utilization (vllm:num_requests_running{pod=~".*prefill.*"}) | Prefill is short and bursty. Sustained saturation here means prompts queue before decode can even start, inflating TTFT | PromQL β Prefill/Decode |
Decode KV cache utilization (vllm:kv_cache_usage_perc{pod=~".*decode.*"}) | Decode holds KV for the full generation. Above ~0.9 the decode pool preempts or rejects, so decode β not prefill β is usually the scaling bottleneck | PromQL β Prefill/Decode |
P/D decision ratio (llm_d_epp_disagg_decision_total) | Confirms the router is actually splitting prefill and decode (decision_type="prefill-decode"). A ratio drifting toward decode-only means requests are falling back to aggregated serving | PromQL β Prefill/Decode |
EPP scheduler e2e latency (llm_d_epp_scheduler_e2e_duration_seconds) | Rising scheduler latency with healthy pools points at the routing layer, not the model servers | PromQL β Tier 1 |
TTFT vs. ITL split (vllm:time_to_first_token_seconds, vllm:inter_token_latency_seconds) | TTFT regressions localize to prefill or KV transfer; ITL regressions localize to decode. Splitting them tells you which pool to investigate | Metrics β vLLM |
SGLang deployments expose the equivalent signals under
sglang:*(sglang:num_running_reqs,sglang:token_usage); the PromQL reference lists both.
Common failure modesβ
- TTFT regression, decode healthy β prefill pool is saturated or KV transfer is stalling. Check prefill utilization and TTFT together; if prefill is idle but TTFT is high, suspect the configured KV-transfer backend (NIXL, Mooncake, or another backend; see the SGLang operations doc for the prefill-side KV-strand caveat).
- ITL regression, prefill healthy β decode pool is the bottleneck. Check decode KV cache utilization; sustained values near 1.0 mean the decode role needs more replicas or a larger TP degree.
- Both pools underutilized but latency high β routing problem. Check the P/D decision ratio (
llm_d_epp_disagg_decision_total) and EPP scheduler e2e latency (llm_d_epp_scheduler_e2e_duration_seconds) before touching the model servers. cks-mooncake: no RDMA in pod β check thatrdma/ibappears in the node's allocatable resources (kubectl describe node) and that the RDMA device plugin is running.cks-mooncake: MooncakeConnector fails to initialize β verify thatmooncake-transfer-engineis installed in the image (pip show mooncake-transfer-engineinside the pod) and that the bootstrap port is free.cks-mooncake: KV transfer failures β confirm that the prefill and decode pods can reach each other over the RDMA network.
For alert rules covering these signals, see Alerting.
Cleanupβ
To remove the deployed components:
kubectl delete -n ${NAMESPACE} -k ${REPO_ROOT}/guides/${GUIDE_NAME}/modelserver/${ACCELERATOR_TYPE}/${MODEL_SERVER}/${INFRA_PROVIDER}
On DisaggregatedSet paths, deleting the set cascades to its LeaderWorkerSets, their pods, and per-slice Services. For TPU dynamic sub-slices, delete the overlay before removing any node pools so that Kueue releases the Slice resources it created.
helm uninstall ${GUIDE_NAME} -n ${NAMESPACE}
kubectl delete -n ${NAMESPACE} -k ${REPO_ROOT}/guides/recipes/modelserver/components/monitoring-pd --ignore-not-found=true
kubectl delete namespace ${NAMESPACE}