GPUStack Operator

Prefill and Decode Walkthrough

Deploy a disaggregated large language model (LLM) topology with dedicated prefill and decode roles fronted by a Router. Observe joint admission across both roles, then send completion requests through the router’s single endpoint.

Contents

Summary

Prefill/Decode (PD) disaggregation separates compute-bound prompt prefill from memory-bandwidth-bound token decoding. A compute-heavy Prefill role processes initial prompt context. A memory-bandwidth-bound Decode role generates subsequent tokens.

A managed Router fronts both roles and exposes one cluster-internal HTTP endpoint. It parses prompt requests, forwards prefill work, and coordinates key-value (KV) cache transfer to decoders. Kueue schedules both roles as a joint unit, preventing half-admitted workloads.

The H100 comparison allocated six GPUs to each configuration. It supports PD when smoother generation under long-prompt concurrency outweighs first-token delay. At concurrency 16, long-prompt inter-token latency (ITL) p99 improved 25.6×–27.8×, while first-token latency rose substantially. The mixed steady stream had 23.3× lower maximum ITL, with no p99 improvement. See Prefill and Decode Benchmark for throughput, tails and the decode-heavy outlier.

Object Scope Creator Purpose
ModelArtifact namespace user the Qwen3-32B weights the deployment serves
ModelPrefetch namespace user the artifact cached onto the H100 node before serving
InstanceType cluster operator the H100 accelerator group each role requests
ModelDeployment namespace user one Prefill and two Decode replicas behind a router
Router Deployment and Service namespace operator the one HTTP endpoint requests are sent to

Environment

The deployment and probe below were captured on this cluster:

Item Captured setup
Cluster Kubernetes 1.35.7; one CPU node and one GPU node (gpu-node-0)
GPUs 8 × NVIDIA H100 80 GB HBM3 SXM5; six GPUs assigned to serving
InstanceType bench-gpu, configured for the H100 accelerator group
Model Qwen/Qwen3-32B from ModelArtifact qwen3-32b, served as /models through node model delivery
Engine and router vLLM gpustack/runner:cuda13.0-vllm0.30.0 with llm-d-router
KV transfer Mooncake over TCP; the default protocol renders as tcp

Each engine Pod exposed only its two assigned GPUs, with private IPC and /dev/shm. No remote direct memory access (RDMA) interface was attached. NVLink carried TP = 2 traffic inside each Pod. Cross-Pod Mooncake KV transfer used TCP sockets over eth0, despite the host’s NVLink fabric.

Step 1: Start disaggregated serving

Enable node model delivery and follow the model prefetch procedure with namespace gpustack-bench. Create ModelArtifact qwen3-32b from Qwen/Qwen3-32B at revision 9216db5781bf21249d130ec9da846c4624c16137, then prefetch it onto the H100 node. Use that artifact and namespace throughout the procedure instead of its DeepSeek example.

The captured run used the custom bench-gpu InstanceType for the same H100 accelerator group. This reusable manifest uses the discovered H100 InstanceType name. Replace the two instanceType values with bench-gpu to reproduce the captured configuration exactly.

The deployment requests one Prefill replica and two Decode replicas, each with two GPUs. All three engine Pods and the Router ran on gpu-node-0.

Save the following manifest as pd-deployment.yaml:

yaml
apiVersion: worker.gpustack.ai/v1
kind: ModelDeployment
metadata:
  name: pd-arm2-pd
  namespace: gpustack-bench
spec:
  model:
    name: /models
    artifactRef:
      name: qwen3-32b
  engine:
    name: vLLM
    image: gpustack/runner:cuda13.0-vllm0.30.0
  router:
    name: llm-d-router
  roles:
    - name: prefill
      kind: Prefill
      replicas: 1
      size: 1
      instanceType: gpustack--nvidia-h100-80gb-hbm3-linux-amd64
      image: gpustack/runner:cuda13.0-vllm0.30.0
      imagePullPolicy: IfNotPresent
      resources:
        accelerator: "2"
      extraArgs:
        - --tensor-parallel-size
        - "2"
        - --max-model-len
        - "16384"
        - --max-num-seqs
        - "64"
        - --gpu-memory-utilization
        - "0.90"
        - --enable-scale-out
    - name: decode
      kind: Decode
      replicas: 2
      size: 1
      instanceType: gpustack--nvidia-h100-80gb-hbm3-linux-amd64
      image: gpustack/runner:cuda13.0-vllm0.30.0
      imagePullPolicy: IfNotPresent
      resources:
        accelerator: "2"
      extraArgs:
        - --tensor-parallel-size
        - "2"
        - --max-model-len
        - "16384"
        - --max-num-seqs
        - "64"
        - --gpu-memory-utilization
        - "0.90"
        - --enable-scale-out

Apply the manifest and inspect the captured status:

bash
kubectl apply -f pd-deployment.yaml
kubectl get modeldeployment pd-arm2-pd -n gpustack-bench \
  -o custom-columns=NAME:.metadata.name,ENGINE:.spec.engine.name,ROLES:.status.roleSummary,PHASE:.status.phase
text
NAME        ENGINE   ROLES    PHASE
pd-arm2-pd  vLLM     1R1P2D   Ready

The recorded Pods were ready and co-located on gpu-node-0:

text
NAME                                READY   STATUS    NODE
pd-arm2-pd-decode-2xglg             2/2     Running   gpu-node-0
pd-arm2-pd-decode-r2zt4             2/2     Running   gpu-node-0
pd-arm2-pd-prefill-xmjvs            1/1     Running   gpu-node-0
pd-arm2-pd-router-6b9fcd6b75-5ngnj   2/2     Running   gpu-node-0

Each engine process uses tensor parallelism (TP) across its two requested accelerators. The --tensor-parallel-size 2 flag sets TP = 2. The deployment has no Encode role, so both roles set --enable-scale-out; see Counting engine tokens .

Step 2: Pin the transfer protocol

Disaggregated serving requires prompt KV tensors to flow from prefill workers to decode workers. The pair in Step 1 hands blocks over directly. An unset spec.kvTransfer.protocol renders tcp. Pin the protocol explicitly. Add the lines marked + to pd-deployment.yaml:

diff
 apiVersion: worker.gpustack.ai/v1
 kind: ModelDeployment
 metadata:
   name: pd-arm2-pd
   namespace: gpustack-bench
 spec:
+  kvTransfer:
+    protocol: TCP
   router:
     name: llm-d-router

Apply the update:

bash
kubectl apply -f pd-deployment.yaml

The recorded manifest leaves the protocol unset, which renders tcp by default. The protocol is deployment-wide because it describes one link between the two roles. It accepts Auto, TCP, RDMA, EFA, CANN and ROCM; Auto renders tcp too. An edit restarts every role because the value renders into both ends’ arguments. RDMA and EFA also need a matching device request on each role. See the RDMA walkthrough .

The captured engine commands rendered these transfer settings:

text
Prefill main:
  --kv-transfer-config '{"kv_connector":"MooncakeConnector","kv_role":"kv_producer","kv_connector_extra_config":{"mooncake_protocol":"tcp"}}'
  --kv-events-config enables the publisher on ports 5557 and 5558
  --port 8000

Decode main:
  --kv-transfer-config '{"kv_connector":"MooncakeConnector","kv_role":"kv_consumer","kv_connector_extra_config":{"mooncake_protocol":"tcp"}}'
  --port 8200

Decode routing-proxy init container:
  --port=8000 --model-server-port=8200 --kv-connector=mooncake
  --mooncake-bootstrap-port=8998 --secure-proxy=false

The InfiniBand RDMA follow-up set spec.kvTransfer.protocol to RDMA and requested resources.interface = "2" on both roles. Runtime logs verified the InfiniBand KV path; the configuration change alone is not transport proof.

A shared store through spec.kvCache.poolRef is part of the deployment identity. Declare it in the first apply. Adding it later is refused because a different cache describes a different deployment. See deployment identity fields .

Step 3: Verify the router

The captured Router endpoint is:

text
http://pd-arm2-pd-router.gpustack-bench.svc:8000

Send a completion request through the Router from inside the cluster:

bash
kubectl -n gpustack-bench run test-client --rm -i --restart=Never \
  --image=curlimages/curl -- \
  curl -s http://pd-arm2-pd-router.gpustack-bench.svc:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "/models",
    "messages": [{"role": "user", "content": "Explain prefill and decode disaggregation."}],
    "max_tokens": 16
  }'

The recorded burst probe sent 16 requests at concurrency 8, with 8,192 input tokens and 64 output tokens. All 16 succeeded, with zero empty bodies or errors:

json
{"requests":16,"concurrency":8,"input_tokens":8192,"output_tokens":64,"ok":16,"empty":0,"bad":0}

The Router delegates prompt processing and coordinates the roles. Mooncake transfers KV tensors between the engines, and the Router returns generated tokens. status.endpoint remains empty until the Router Pod passes its readiness check.

Step 4: Inspect joint admission

The operator manages Kueue workloads across all declared roles simultaneously. The captured workloads were all admitted into the captured custom bench-gpu queue. The reusable discovered InstanceType uses its own generated queue:

bash
kubectl -n gpustack-bench get workloads
text
NAME                              QUEUE                             RESERVED IN   ADMITTED
gpustack-fnv64-ab5121bc91ece976   gpustack-fnv64-889bba71d87d29e2   bench-gpu     True
gpustack-fnv64-ab5122bc91eceb29   gpustack-fnv64-889bba71d87d29e2   bench-gpu     True
gpustack-fnv64-d6b4e8ae20539d37   gpustack-fnv64-889bba71d87d29e2   bench-gpu     True

You can adjust replicas independently on either role to balance prompt versus token throughput. See Rollout behavior for editable role fields and replica replacement semantics.

Step 5: Release the application

After testing disaggregated serving, delete the deployment to release GPUs and Router services:

bash
kubectl -n gpustack-bench delete modeldeployment pd-arm2-pd
kubectl -n gpustack-bench get pods,services -l app.kubernetes.io/instance=pd-arm2-pd

Verify that all application GPU claims are released. The post-deletion PD Devices snapshot recorded the following availability:

text
node: gpu-node-0
accelerator: NVIDIA H100 80GB HBM3
count: 8
remaining per accelerator: 1600000

This snapshot establishes released accelerator claims. Model-cache contents and cluster infrastructure remain outside its scope.

Limitations

  • The recording uses one GPU node with eight H100 accelerators and co-locates all six serving GPUs and the Router on that node. It does not measure placement across multiple GPU nodes.
  • The captured transfer path is TCP through Mooncake. RDMA, EFA, CANN and ROCM paths require their own device requests and runtime validation.
  • The short completion probe covers readiness and inference. The separate benchmark measures all 16 fixed-shape cells and mixed traffic; its results remain limited to the captured H100 configuration.
  • Joint gang admission across all roles (PodGroup), role-set immutability after creation and per-replica rollout replacement are covered in Prefill and decode and Rollout behavior .
  • Disaggregated serving requires a ready Router (status.endpoint is populated only while the Router Pod is ready); see Router configuration .

Troubleshooting

Symptom Signal Check & Mitigation
ModelDeployment stuck in Starting, Kueue composes no Workload Condition: QuotaReserved=False/PodGroupIncomplete Fewer replicas exist than a role declares. Read the message for the counts, then check the missing or unschedulable Pods
No Workload asks for quota any more Condition: QuotaReserved=False/Parked The groups could not all be placed, so their Workloads were deactivated. An identical re-apply does not clear this. Free the capacity and reactivate the Workloads, or recreate the deployment
Part of the deployment was taken by another workload Condition: QuotaReserved=False/PreemptedInPart A higher-priority workload reclaimed some replicas. The replicas that kept their quota hold accelerators until the reclaimed ones are admitted again or the deployment is deleted. Check what took the quota
status.endpoint is empty Condition: RouterReady=False/NoReadyReplicas The Router has no ready replica. It may still pull its image or fail readiness. Inspect the Router Pod
Router is not created Condition: RouterReady=False/RenderFailed The Router render was refused as a spec problem, so no Router Pod exists. Read the condition message and check spec.router
Disaggregated roles fail KV transfer Log: engine logs on both roles The protocol is deployment-wide, so the two ends cannot name different values. Check that the image carries the matching Mooncake build, that the device request fits the protocol and that the nodes can reach each other
Router sees no cache events Condition: KVEventsPublishing=False/PublisherDisabled A role that produces cache blocks renders no publisher. A routed SGLang deployment always reports this because only vLLM renders one. The condition reports configuration, not traffic. See KV events
Missing role readiness Condition: RoleKindsReady=False One of the role kinds (Prefill or Decode) has zero ready replicas; check failed engine Pods